Coder workspace on archive
diff --git a/src/browser/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts
index 52053e71e3..41f8eeb206 100644
--- a/src/browser/stores/WorkspaceStore.test.ts
+++ b/src/browser/stores/WorkspaceStore.test.ts
@@ -2,7 +2,11 @@ import { describe, expect, it, beforeEach, afterEach, mock, type Mock } from "bu
import type { DisplayedMessage } from "@/common/types/message";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import type { StreamStartEvent, ToolCallStartEvent } from "@/common/types/stream";
-import type { WorkspaceActivitySnapshot, WorkspaceChatMessage } from "@/common/orpc/types";
+import type {
+ WorkspaceActivitySnapshot,
+ WorkspaceChatMessage,
+ WorkspaceLspDiagnosticsSnapshot,
+} from "@/common/orpc/types";
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
import { DEFAULT_AUTO_COMPACTION_THRESHOLD_PERCENT } from "@/common/constants/ui";
import {
@@ -15,6 +19,8 @@ import type { TodoItem } from "@/common/types/tools";
import { WorkspaceStore } from "./WorkspaceStore";
import type { ResponseCompleteEvent } from "@/browser/utils/messages/responseCompletionMetadata";
+type WorkspaceStoreClient = NonNullable
[0]>;
+
interface LoadMoreResponse {
messages: WorkspaceChatMessage[];
nextCursor: { beforeHistorySequence: number; beforeMessageId?: string | null } | null;
@@ -50,6 +56,119 @@ const mockHistoryLoadMore = mock(
);
const mockActivityList = mock(() => Promise.resolve>({}));
+interface ControlledAsyncIterator {
+ iterator: AsyncGenerator;
+ push: (value: T) => void;
+ fail: (error: unknown) => void;
+ end: () => void;
+}
+
+function createControlledAsyncIterator(signal?: AbortSignal): ControlledAsyncIterator {
+ const queue: T[] = [];
+ let ended = false;
+ let resolveNext: ((result: IteratorResult) => void) | null = null;
+ let rejectNext: ((error: Error) => void) | null = null;
+
+ const clearPendingNext = () => {
+ resolveNext = null;
+ rejectNext = null;
+ };
+
+ const push = (value: T) => {
+ if (ended) {
+ return;
+ }
+ if (resolveNext) {
+ const resolve = resolveNext;
+ clearPendingNext();
+ resolve({ value, done: false });
+ return;
+ }
+ queue.push(value);
+ };
+
+ const fail = (error: unknown) => {
+ if (ended) {
+ return;
+ }
+ ended = true;
+ const nextError = error instanceof Error ? error : new Error(String(error));
+ if (rejectNext) {
+ const reject = rejectNext;
+ clearPendingNext();
+ reject(nextError);
+ }
+ };
+
+ const end = () => {
+ if (ended) {
+ return;
+ }
+ ended = true;
+ if (resolveNext) {
+ const resolve = resolveNext;
+ clearPendingNext();
+ resolve({ value: undefined as void, done: true });
+ }
+ };
+
+ signal?.addEventListener("abort", end, { once: true });
+
+ const iterator: AsyncGenerator = {
+ async next(): Promise> {
+ if (queue.length > 0) {
+ return { value: queue.shift()!, done: false };
+ }
+ if (ended) {
+ return { value: undefined as void, done: true };
+ }
+ return await new Promise>((resolve, reject) => {
+ resolveNext = resolve;
+ rejectNext = reject;
+ });
+ },
+ return(): Promise> {
+ end();
+ return Promise.resolve({ value: undefined as void, done: true });
+ },
+ throw(error: unknown): Promise> {
+ end();
+ return Promise.reject(error instanceof Error ? error : new Error(String(error)));
+ },
+ [Symbol.asyncDispose](): Promise {
+ end();
+ return Promise.resolve();
+ },
+ [Symbol.asyncIterator]() {
+ return this;
+ },
+ };
+
+ return { iterator, push, fail, end };
+}
+
+const lspDiagnosticsSubscriptions: Array<{
+ workspaceId: string;
+ signal?: AbortSignal;
+ push: (snapshot: WorkspaceLspDiagnosticsSnapshot) => void;
+ end: () => void;
+}> = [];
+
+function emitLspDiagnosticsSnapshot(snapshot: WorkspaceLspDiagnosticsSnapshot): void {
+ for (const subscription of lspDiagnosticsSubscriptions) {
+ if (subscription.workspaceId === snapshot.workspaceId) {
+ subscription.push(snapshot);
+ }
+ }
+}
+
+function clearLspDiagnosticsSubscriptions(): void {
+ for (const subscription of lspDiagnosticsSubscriptions) {
+ subscription.end();
+ }
+ lspDiagnosticsSubscriptions.length = 0;
+}
+
type WorkspaceActivityEvent =
| {
type: "activity";
@@ -100,6 +219,23 @@ const mockSetAutoCompactionThreshold = mock(() =>
Promise.resolve({ success: true, data: undefined })
);
const mockGetStartupAutoRetryModel = mock(() => Promise.resolve({ success: true, data: null }));
+const mockListLspDiagnostics = mock(({ workspaceId }: { workspaceId: string }) =>
+ Promise.resolve({ workspaceId, diagnostics: [] })
+);
+const mockSubscribeLspDiagnostics = mock(
+ ({ workspaceId }: { workspaceId: string }, options?: { signal?: AbortSignal }) => {
+ const controlled = createControlledAsyncIterator(
+ options?.signal
+ );
+ lspDiagnosticsSubscriptions.push({
+ workspaceId,
+ signal: options?.signal,
+ push: controlled.push,
+ end: controlled.end,
+ });
+ return controlled.iterator;
+ }
+);
const mockClient = {
workspace: {
@@ -114,6 +250,10 @@ const mockClient = {
},
setAutoCompactionThreshold: mockSetAutoCompactionThreshold,
getStartupAutoRetryModel: mockGetStartupAutoRetryModel,
+ lsp: {
+ listDiagnostics: mockListLspDiagnostics,
+ subscribeDiagnostics: mockSubscribeLspDiagnostics,
+ },
},
terminal: {
activity: {
@@ -292,6 +432,9 @@ describe("WorkspaceStore", () => {
mockTerminalActivitySubscribe.mockClear();
mockSetAutoCompactionThreshold.mockClear();
mockGetStartupAutoRetryModel.mockClear();
+ mockListLspDiagnostics.mockClear();
+ mockSubscribeLspDiagnostics.mockClear();
+ clearLspDiagnosticsSubscriptions();
global.window.localStorage?.clear?.();
mockHistoryLoadMore.mockResolvedValue({
messages: [],
@@ -327,6 +470,7 @@ describe("WorkspaceStore", () => {
afterEach(() => {
store.dispose();
+ clearLspDiagnosticsSubscriptions();
});
describe("pinned todo auto-collapse", () => {
@@ -5375,4 +5519,390 @@ describe("WorkspaceStore", () => {
expect(usage.lastContextUsage?.model).toBe("claude-3-5-sonnet-20241022");
});
});
+
+ describe("workspace LSP diagnostics snapshots", () => {
+ it("starts empty and replaces the cached snapshot after the first payload", async () => {
+ const workspaceId = "lsp-diagnostics-snapshot";
+ createAndAddWorkspace(store, workspaceId);
+
+ expect(store.getWorkspaceLspDiagnosticsSnapshot(workspaceId)).toBeNull();
+
+ const unsubscribe = store.subscribeLspDiagnostics(workspaceId, () => undefined);
+ const subscribed = await waitUntil(() => mockSubscribeLspDiagnostics.mock.calls.length === 1);
+ expect(subscribed).toBe(true);
+
+ emitLspDiagnosticsSnapshot({
+ workspaceId,
+ diagnostics: [
+ {
+ uri: "file:///workspace/src/example.ts",
+ path: "/workspace/src/example.ts",
+ serverId: "typescript",
+ rootUri: "file:///workspace",
+ version: 1,
+ diagnostics: [
+ {
+ range: {
+ start: { line: 1, character: 1 },
+ end: { line: 1, character: 5 },
+ },
+ severity: 1,
+ source: "tsserver",
+ message: "first snapshot",
+ },
+ ],
+ receivedAtMs: 1,
+ },
+ ],
+ });
+
+ const received = await waitUntil(
+ () =>
+ store.getWorkspaceLspDiagnosticsSnapshot(workspaceId)?.diagnostics[0]?.diagnostics[0]
+ ?.message === "first snapshot"
+ );
+ expect(received).toBe(true);
+
+ emitLspDiagnosticsSnapshot({ workspaceId, diagnostics: [] });
+ const cleared = await waitUntil(
+ () => store.getWorkspaceLspDiagnosticsSnapshot(workspaceId)?.diagnostics.length === 0
+ );
+ expect(cleared).toBe(true);
+
+ unsubscribe();
+ });
+
+ it("shares one backend subscription across listeners and clears state on the last unsubscribe", async () => {
+ const workspaceId = "lsp-diagnostics-refcount";
+ createAndAddWorkspace(store, workspaceId);
+
+ const unsubscribeOne = store.subscribeLspDiagnostics(workspaceId, () => undefined);
+ const unsubscribeTwo = store.subscribeLspDiagnostics(workspaceId, () => undefined);
+ const subscribed = await waitUntil(() => mockSubscribeLspDiagnostics.mock.calls.length === 1);
+ expect(subscribed).toBe(true);
+ expect(mockSubscribeLspDiagnostics).toHaveBeenCalledTimes(1);
+
+ emitLspDiagnosticsSnapshot({ workspaceId, diagnostics: [] });
+ await waitUntil(
+ () => store.getWorkspaceLspDiagnosticsSnapshot(workspaceId)?.workspaceId === workspaceId
+ );
+
+ unsubscribeOne();
+ expect(lspDiagnosticsSubscriptions[0]?.signal?.aborted).toBe(false);
+ expect(store.getWorkspaceLspDiagnosticsSnapshot(workspaceId)?.workspaceId).toBe(workspaceId);
+
+ unsubscribeTwo();
+ const aborted = await waitUntil(
+ () => lspDiagnosticsSubscriptions[0]?.signal?.aborted === true
+ );
+ expect(aborted).toBe(true);
+ expect(store.getWorkspaceLspDiagnosticsSnapshot(workspaceId)).toBeNull();
+ });
+
+ it("re-subscribes active diagnostics listeners after the client reconnects", async () => {
+ const workspaceId = "lsp-diagnostics-reconnect";
+ createAndAddWorkspace(store, workspaceId);
+
+ const unsubscribe = store.subscribeLspDiagnostics(workspaceId, () => undefined);
+ const subscribed = await waitUntil(() => mockSubscribeLspDiagnostics.mock.calls.length === 1);
+ expect(subscribed).toBe(true);
+
+ emitLspDiagnosticsSnapshot({
+ workspaceId,
+ diagnostics: [
+ {
+ uri: "file:///workspace/src/example.ts",
+ path: "/workspace/src/example.ts",
+ serverId: "typescript",
+ rootUri: "file:///workspace",
+ version: 1,
+ diagnostics: [
+ {
+ range: {
+ start: { line: 1, character: 1 },
+ end: { line: 1, character: 5 },
+ },
+ severity: 1,
+ source: "tsserver",
+ message: "before reconnect",
+ },
+ ],
+ receivedAtMs: 1,
+ },
+ ],
+ });
+ const firstReceived = await waitUntil(
+ () =>
+ store.getWorkspaceLspDiagnosticsSnapshot(workspaceId)?.diagnostics[0]?.diagnostics[0]
+ ?.message === "before reconnect"
+ );
+ expect(firstReceived).toBe(true);
+
+ store.setClient(null);
+ const oldSignal = lspDiagnosticsSubscriptions[0]?.signal;
+ const oldAborted = await waitUntil(() => oldSignal?.aborted === true);
+ expect(oldAborted).toBe(true);
+
+ store.setClient(mockClient as unknown as WorkspaceStoreClient);
+ const resubscribed = await waitUntil(
+ () => mockSubscribeLspDiagnostics.mock.calls.length === 2
+ );
+ expect(resubscribed).toBe(true);
+
+ emitLspDiagnosticsSnapshot({
+ workspaceId,
+ diagnostics: [
+ {
+ uri: "file:///workspace/src/example.ts",
+ path: "/workspace/src/example.ts",
+ serverId: "typescript",
+ rootUri: "file:///workspace",
+ version: 2,
+ diagnostics: [
+ {
+ range: {
+ start: { line: 2, character: 1 },
+ end: { line: 2, character: 5 },
+ },
+ severity: 1,
+ source: "tsserver",
+ message: "after reconnect",
+ },
+ ],
+ receivedAtMs: 2,
+ },
+ ],
+ });
+ const secondReceived = await waitUntil(
+ () =>
+ store.getWorkspaceLspDiagnosticsSnapshot(workspaceId)?.diagnostics[0]?.diagnostics[0]
+ ?.message === "after reconnect"
+ );
+ expect(secondReceived).toBe(true);
+
+ unsubscribe();
+ });
+
+ it("retries LSP diagnostics subscriptions after an unexpected stream end", async () => {
+ store.setClient(null);
+
+ const customSubscriptions: Array<{
+ signal?: AbortSignal;
+ push: (snapshot: WorkspaceLspDiagnosticsSnapshot) => void;
+ end: () => void;
+ }> = [];
+ const customSubscribeDiagnostics = mock(
+ (_input: { workspaceId: string }, options?: { signal?: AbortSignal }) => {
+ const controlled = createControlledAsyncIterator(
+ options?.signal
+ );
+ customSubscriptions.push({
+ signal: options?.signal,
+ push: controlled.push,
+ end: controlled.end,
+ });
+ return controlled.iterator;
+ }
+ );
+ const customClient = {
+ workspace: {
+ ...mockClient.workspace,
+ lsp: {
+ ...mockClient.workspace.lsp,
+ subscribeDiagnostics: customSubscribeDiagnostics,
+ },
+ },
+ terminal: mockClient.terminal,
+ };
+ store.setClient(customClient as unknown as WorkspaceStoreClient);
+
+ const workspaceId = "lsp-diagnostics-retry";
+ createAndAddWorkspace(store, workspaceId);
+
+ const unsubscribe = store.subscribeLspDiagnostics(workspaceId, () => undefined);
+ const subscribed = await waitUntil(() => customSubscribeDiagnostics.mock.calls.length === 1);
+ expect(subscribed).toBe(true);
+
+ customSubscriptions[0]?.push({
+ workspaceId,
+ diagnostics: [
+ {
+ uri: "file:///workspace/src/example.ts",
+ path: "/workspace/src/example.ts",
+ serverId: "typescript",
+ rootUri: "file:///workspace",
+ version: 1,
+ diagnostics: [
+ {
+ range: {
+ start: { line: 1, character: 1 },
+ end: { line: 1, character: 5 },
+ },
+ severity: 1,
+ source: "tsserver",
+ message: "before retry",
+ },
+ ],
+ receivedAtMs: 1,
+ },
+ ],
+ });
+ const firstReceived = await waitUntil(
+ () =>
+ store.getWorkspaceLspDiagnosticsSnapshot(workspaceId)?.diagnostics[0]?.diagnostics[0]
+ ?.message === "before retry"
+ );
+ expect(firstReceived).toBe(true);
+
+ customSubscriptions[0]?.end();
+ const showedRetryState = await waitUntil(
+ () =>
+ store.getWorkspaceLspDiagnosticsViewState(workspaceId).connection.status === "retrying"
+ );
+ expect(showedRetryState).toBe(true);
+ expect(store.getWorkspaceLspDiagnosticsViewState(workspaceId).connection.errorMessage).toBe(
+ "The diagnostics stream ended unexpectedly."
+ );
+
+ const retried = await waitUntil(
+ () => customSubscribeDiagnostics.mock.calls.length === 2,
+ 4000
+ );
+ expect(retried).toBe(true);
+ expect(customSubscriptions[1]).toBeDefined();
+
+ customSubscriptions[1]?.push({
+ workspaceId,
+ diagnostics: [
+ {
+ uri: "file:///workspace/src/example.ts",
+ path: "/workspace/src/example.ts",
+ serverId: "typescript",
+ rootUri: "file:///workspace",
+ version: 2,
+ diagnostics: [
+ {
+ range: {
+ start: { line: 2, character: 1 },
+ end: { line: 2, character: 5 },
+ },
+ severity: 1,
+ source: "tsserver",
+ message: "after retry",
+ },
+ ],
+ receivedAtMs: 2,
+ },
+ ],
+ });
+ const secondReceived = await waitUntil(
+ () =>
+ store.getWorkspaceLspDiagnosticsSnapshot(workspaceId)?.diagnostics[0]?.diagnostics[0]
+ ?.message === "after retry"
+ );
+ expect(secondReceived).toBe(true);
+ expect(store.getWorkspaceLspDiagnosticsViewState(workspaceId).connection).toEqual({
+ status: "ready",
+ errorMessage: null,
+ });
+ expect(customSubscriptions[1]?.signal?.aborted).toBe(false);
+
+ unsubscribe();
+ });
+
+ it("records the latest diagnostics subscription error until a fresh snapshot arrives", async () => {
+ store.setClient(null);
+
+ const customSubscriptions: Array<{
+ signal?: AbortSignal;
+ push: (snapshot: WorkspaceLspDiagnosticsSnapshot) => void;
+ fail: (error: unknown) => void;
+ end: () => void;
+ }> = [];
+ const customSubscribeDiagnostics = mock(
+ (_input: { workspaceId: string }, options?: { signal?: AbortSignal }) => {
+ const controlled = createControlledAsyncIterator(
+ options?.signal
+ );
+ customSubscriptions.push({
+ signal: options?.signal,
+ push: controlled.push,
+ fail: controlled.fail,
+ end: controlled.end,
+ });
+ return controlled.iterator;
+ }
+ );
+ const customClient = {
+ workspace: {
+ ...mockClient.workspace,
+ lsp: {
+ ...mockClient.workspace.lsp,
+ subscribeDiagnostics: customSubscribeDiagnostics,
+ },
+ },
+ terminal: mockClient.terminal,
+ };
+ store.setClient(customClient as unknown as WorkspaceStoreClient);
+
+ const workspaceId = "lsp-diagnostics-error";
+ createAndAddWorkspace(store, workspaceId);
+
+ const unsubscribe = store.subscribeLspDiagnostics(workspaceId, () => undefined);
+ const subscribed = await waitUntil(() => customSubscribeDiagnostics.mock.calls.length === 1);
+ expect(subscribed).toBe(true);
+
+ customSubscriptions[0]?.fail(new Error("socket dropped"));
+ const showedRetryState = await waitUntil(
+ () =>
+ store.getWorkspaceLspDiagnosticsViewState(workspaceId).connection.status === "retrying"
+ );
+ expect(showedRetryState).toBe(true);
+ expect(store.getWorkspaceLspDiagnosticsViewState(workspaceId).connection.errorMessage).toBe(
+ "socket dropped"
+ );
+
+ const retried = await waitUntil(
+ () => customSubscribeDiagnostics.mock.calls.length === 2,
+ 4000
+ );
+ expect(retried).toBe(true);
+
+ customSubscriptions[1]?.push({
+ workspaceId,
+ diagnostics: [],
+ });
+ const recovered = await waitUntil(
+ () => store.getWorkspaceLspDiagnosticsViewState(workspaceId).connection.status === "ready"
+ );
+ expect(recovered).toBe(true);
+ expect(
+ store.getWorkspaceLspDiagnosticsViewState(workspaceId).connection.errorMessage
+ ).toBeNull();
+
+ unsubscribe();
+ });
+
+ it("aborts LSP diagnostics subscriptions when a workspace is removed", async () => {
+ const workspaceId = "lsp-diagnostics-remove";
+ createAndAddWorkspace(store, workspaceId);
+
+ store.subscribeLspDiagnostics(workspaceId, () => undefined);
+ const subscribed = await waitUntil(() => mockSubscribeLspDiagnostics.mock.calls.length === 1);
+ expect(subscribed).toBe(true);
+
+ emitLspDiagnosticsSnapshot({ workspaceId, diagnostics: [] });
+ await waitUntil(
+ () => store.getWorkspaceLspDiagnosticsSnapshot(workspaceId)?.workspaceId === workspaceId
+ );
+
+ store.removeWorkspace(workspaceId);
+
+ const aborted = await waitUntil(
+ () => lspDiagnosticsSubscriptions[0]?.signal?.aborted === true
+ );
+ expect(aborted).toBe(true);
+ expect(store.getWorkspaceLspDiagnosticsSnapshot(workspaceId)).toBeNull();
+ });
+ });
});
diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts
index 176502ff1a..cd529c54ab 100644
--- a/src/browser/stores/WorkspaceStore.ts
+++ b/src/browser/stores/WorkspaceStore.ts
@@ -4,6 +4,7 @@ import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import type {
WorkspaceActivitySnapshot,
WorkspaceChatMessage,
+ WorkspaceLspDiagnosticsSnapshot,
WorkspaceStatsSnapshot,
OnChatMode,
ProvidersConfigMap,
@@ -303,6 +304,30 @@ const SUBSCRIPTION_RETRY_MAX_MS = 5000;
const SUBSCRIPTION_STALL_TIMEOUT_MS = 10_000;
const SUBSCRIPTION_STALL_CHECK_INTERVAL_MS = 2_000;
+export interface WorkspaceLspDiagnosticsConnectionState {
+ status: "loading" | "ready" | "retrying";
+ errorMessage: string | null;
+}
+
+export interface WorkspaceLspDiagnosticsViewState {
+ snapshot: WorkspaceLspDiagnosticsSnapshot | null;
+ connection: WorkspaceLspDiagnosticsConnectionState;
+}
+
+const DEFAULT_WORKSPACE_LSP_DIAGNOSTICS_CONNECTION_STATE: WorkspaceLspDiagnosticsConnectionState = {
+ status: "loading",
+ errorMessage: null,
+};
+
+function formatLspDiagnosticsSubscriptionError(error: unknown): string | null {
+ if (error instanceof Error) {
+ const message = error.message.trim();
+ return message.length > 0 ? message : null;
+ }
+
+ return null;
+}
+
interface ValidationIssue {
path?: Array;
message?: string;
@@ -581,6 +606,17 @@ export class WorkspaceStore {
// Per-workspace listener refcount for useWorkspaceStatsSnapshot().
// Used to only subscribe to backend stats when something in the UI is actually reading them.
private statsListenerCounts = new Map();
+
+ // Workspace LSP diagnostics snapshots and connection state (from workspace.lsp.subscribeDiagnostics)
+ private workspaceLspDiagnostics = new Map();
+ private workspaceLspDiagnosticsConnectionStates = new Map<
+ string,
+ WorkspaceLspDiagnosticsConnectionState
+ >();
+ private lspDiagnosticsStore = new MapStore();
+ private lspDiagnosticsUnsubscribers = new Map void>();
+ private lspDiagnosticsListenerCounts = new Map();
+
// Cumulative session usage (from session-usage.json)
private sessionUsage = new Map>();
@@ -1076,11 +1112,25 @@ export class WorkspaceStore {
return;
}
- // Drop stats subscriptions before swapping clients so reconnects resubscribe cleanly.
+ // Drop lazy workspace subscriptions before swapping clients so reconnects resubscribe cleanly.
for (const unsubscribe of this.statsUnsubscribers.values()) {
unsubscribe();
}
this.statsUnsubscribers.clear();
+ for (const unsubscribe of this.lspDiagnosticsUnsubscribers.values()) {
+ unsubscribe();
+ }
+ this.lspDiagnosticsUnsubscribers.clear();
+ for (const workspaceId of this.lspDiagnosticsListenerCounts.keys()) {
+ if (
+ this.setWorkspaceLspDiagnosticsConnectionState(
+ workspaceId,
+ DEFAULT_WORKSPACE_LSP_DIAGNOSTICS_CONNECTION_STATE
+ )
+ ) {
+ this.lspDiagnosticsStore.bump(workspaceId);
+ }
+ }
this.client = client;
this.clientChangeController.abort();
@@ -1105,6 +1155,9 @@ export class WorkspaceStore {
for (const workspaceId of this.statsListenerCounts.keys()) {
this.subscribeToStats(workspaceId);
}
+ for (const workspaceId of this.lspDiagnosticsListenerCounts.keys()) {
+ this.subscribeToLspDiagnostics(workspaceId);
+ }
this.ensureActiveOnChatSubscription();
void this.refreshProvidersConfig(client);
@@ -1388,6 +1441,147 @@ export class WorkspaceStore {
});
}
+ private canContinueLspDiagnosticsSubscription(
+ workspaceId: string,
+ client: RouterClient,
+ signal: AbortSignal
+ ): boolean {
+ return (
+ !signal.aborted &&
+ this.client === client &&
+ this.isWorkspaceRegistered(workspaceId) &&
+ (this.lspDiagnosticsListenerCounts.get(workspaceId) ?? 0) > 0
+ );
+ }
+
+ private setWorkspaceLspDiagnosticsConnectionState(
+ workspaceId: string,
+ state: WorkspaceLspDiagnosticsConnectionState
+ ): boolean {
+ const current = this.workspaceLspDiagnosticsConnectionStates.get(workspaceId);
+ if (current?.status === state.status && current?.errorMessage === state.errorMessage) {
+ return false;
+ }
+
+ this.workspaceLspDiagnosticsConnectionStates.set(workspaceId, state);
+ return true;
+ }
+
+ private clearWorkspaceLspDiagnosticsConnectionState(workspaceId: string): boolean {
+ return this.workspaceLspDiagnosticsConnectionStates.delete(workspaceId);
+ }
+
+ private subscribeToLspDiagnostics(workspaceId: string): void {
+ const client = this.client;
+ if (!client) {
+ return;
+ }
+
+ if (!this.isWorkspaceRegistered(workspaceId)) {
+ return;
+ }
+ if ((this.lspDiagnosticsListenerCounts.get(workspaceId) ?? 0) <= 0) {
+ return;
+ }
+ if (this.lspDiagnosticsUnsubscribers.has(workspaceId)) {
+ return;
+ }
+
+ this.setWorkspaceLspDiagnosticsConnectionState(
+ workspaceId,
+ DEFAULT_WORKSPACE_LSP_DIAGNOSTICS_CONNECTION_STATE
+ );
+
+ const controller = new AbortController();
+ const { signal } = controller;
+ let iterator: AsyncIterator | null = null;
+
+ (async () => {
+ let attempt = 0;
+
+ while (this.canContinueLspDiagnosticsSubscription(workspaceId, client, signal)) {
+ try {
+ const subscribedIterator = await client.workspace.lsp.subscribeDiagnostics(
+ { workspaceId },
+ { signal }
+ );
+
+ if (!this.canContinueLspDiagnosticsSubscription(workspaceId, client, signal)) {
+ void subscribedIterator.return?.();
+ return;
+ }
+
+ iterator = subscribedIterator;
+
+ for await (const snapshot of subscribedIterator) {
+ if (!this.canContinueLspDiagnosticsSubscription(workspaceId, client, signal)) {
+ return;
+ }
+
+ attempt = 0;
+ queueMicrotask(() => {
+ if (!this.canContinueLspDiagnosticsSubscription(workspaceId, client, signal)) {
+ return;
+ }
+ this.workspaceLspDiagnostics.set(workspaceId, snapshot);
+ this.setWorkspaceLspDiagnosticsConnectionState(workspaceId, {
+ status: "ready",
+ errorMessage: null,
+ });
+ this.lspDiagnosticsStore.bump(workspaceId);
+ });
+ }
+
+ if (!this.canContinueLspDiagnosticsSubscription(workspaceId, client, signal)) {
+ return;
+ }
+
+ console.warn(
+ `[WorkspaceStore] LSP diagnostics subscription ended unexpectedly for ${workspaceId}; retrying...`
+ );
+ if (
+ this.setWorkspaceLspDiagnosticsConnectionState(workspaceId, {
+ status: "retrying",
+ errorMessage: "The diagnostics stream ended unexpectedly.",
+ })
+ ) {
+ this.lspDiagnosticsStore.bump(workspaceId);
+ }
+ } catch (error) {
+ if (!this.canContinueLspDiagnosticsSubscription(workspaceId, client, signal)) {
+ return;
+ }
+ if (!isAbortError(error)) {
+ console.warn(
+ `[WorkspaceStore] Error in LSP diagnostics subscription for ${workspaceId}:`,
+ error
+ );
+ if (
+ this.setWorkspaceLspDiagnosticsConnectionState(workspaceId, {
+ status: "retrying",
+ errorMessage: formatLspDiagnosticsSubscriptionError(error),
+ })
+ ) {
+ this.lspDiagnosticsStore.bump(workspaceId);
+ }
+ }
+ } finally {
+ void iterator?.return?.();
+ iterator = null;
+ }
+
+ const delayMs = calculateSubscriptionBackoffMs(attempt);
+ attempt++;
+ await this.sleepWithAbort(delayMs, signal);
+ }
+ })();
+
+ this.lspDiagnosticsUnsubscribers.set(workspaceId, () => {
+ controller.abort();
+ void iterator?.return?.();
+ });
+ }
+
/**
* Cancel any pending idle state bump for a workspace.
* Used when immediate state visibility is needed (e.g., stream-end).
@@ -1980,6 +2174,21 @@ export class WorkspaceStore {
});
}
+ getWorkspaceLspDiagnosticsViewState(workspaceId: string): WorkspaceLspDiagnosticsViewState {
+ return this.lspDiagnosticsStore.get(workspaceId, () => {
+ return {
+ snapshot: this.workspaceLspDiagnostics.get(workspaceId) ?? null,
+ connection:
+ this.workspaceLspDiagnosticsConnectionStates.get(workspaceId) ??
+ DEFAULT_WORKSPACE_LSP_DIAGNOSTICS_CONNECTION_STATE,
+ };
+ });
+ }
+
+ getWorkspaceLspDiagnosticsSnapshot(workspaceId: string): WorkspaceLspDiagnosticsSnapshot | null {
+ return this.getWorkspaceLspDiagnosticsViewState(workspaceId).snapshot;
+ }
+
/**
* Bump state for a workspace to trigger React re-renders.
* Used by addEphemeralMessage for frontend-only messages.
@@ -2274,6 +2483,47 @@ export class WorkspaceStore {
};
}
+ subscribeLspDiagnostics(workspaceId: string, listener: () => void): () => void {
+ const unsubscribeFromStore = this.lspDiagnosticsStore.subscribeKey(workspaceId, listener);
+
+ const previousCount = this.lspDiagnosticsListenerCounts.get(workspaceId) ?? 0;
+ const nextCount = previousCount + 1;
+ this.lspDiagnosticsListenerCounts.set(workspaceId, nextCount);
+
+ if (previousCount === 0) {
+ this.subscribeToLspDiagnostics(workspaceId);
+ }
+
+ return () => {
+ unsubscribeFromStore();
+
+ const currentCount = this.lspDiagnosticsListenerCounts.get(workspaceId);
+ if (!currentCount) {
+ console.warn(
+ `[WorkspaceStore] LSP diagnostics listener count underflow for ${workspaceId} (already 0)`
+ );
+ return;
+ }
+
+ if (currentCount === 1) {
+ this.lspDiagnosticsListenerCounts.delete(workspaceId);
+
+ const unsubscribe = this.lspDiagnosticsUnsubscribers.get(workspaceId);
+ if (unsubscribe) {
+ unsubscribe();
+ this.lspDiagnosticsUnsubscribers.delete(workspaceId);
+ }
+ this.workspaceLspDiagnostics.delete(workspaceId);
+ this.clearWorkspaceLspDiagnosticsConnectionState(workspaceId);
+ this.lspDiagnosticsStore.bump(workspaceId);
+ this.lspDiagnosticsStore.delete(workspaceId);
+ return;
+ }
+
+ this.lspDiagnosticsListenerCounts.set(workspaceId, currentCount - 1);
+ };
+ }
+
/**
* Subscribe to consumer store changes for a specific workspace.
*/
@@ -3253,6 +3503,9 @@ export class WorkspaceStore {
// Stats snapshots are subscribed lazily via subscribeStats().
this.subscribeToStats(workspaceId);
+ // LSP diagnostics snapshots are subscribed lazily via subscribeLspDiagnostics().
+ this.subscribeToLspDiagnostics(workspaceId);
+
this.ensureActiveOnChatSubscription();
if (!this.client) {
@@ -3300,6 +3553,12 @@ export class WorkspaceStore {
this.statsUnsubscribers.delete(workspaceId);
}
+ const lspDiagnosticsUnsubscribe = this.lspDiagnosticsUnsubscribers.get(workspaceId);
+ if (lspDiagnosticsUnsubscribe) {
+ lspDiagnosticsUnsubscribe();
+ this.lspDiagnosticsUnsubscribers.delete(workspaceId);
+ }
+
const unsubscribe = this.ipcUnsubscribers.get(workspaceId);
if (unsubscribe) {
unsubscribe();
@@ -3328,6 +3587,10 @@ export class WorkspaceStore {
this.workspaceStats.delete(workspaceId);
this.statsStore.delete(workspaceId);
this.statsListenerCounts.delete(workspaceId);
+ this.workspaceLspDiagnostics.delete(workspaceId);
+ this.workspaceLspDiagnosticsConnectionStates.delete(workspaceId);
+ this.lspDiagnosticsStore.delete(workspaceId);
+ this.lspDiagnosticsListenerCounts.delete(workspaceId);
this.historyPagination.delete(workspaceId);
this.preReplayUsageSnapshot.delete(workspaceId);
this.sessionUsage.delete(workspaceId);
@@ -3378,6 +3641,10 @@ export class WorkspaceStore {
unsubscribe();
}
this.statsUnsubscribers.clear();
+ for (const unsubscribe of this.lspDiagnosticsUnsubscribers.values()) {
+ unsubscribe();
+ }
+ this.lspDiagnosticsUnsubscribers.clear();
for (const unsubscribe of this.ipcUnsubscribers.values()) {
unsubscribe();
@@ -3414,6 +3681,10 @@ export class WorkspaceStore {
this.workspaceStats.clear();
this.statsStore.clear();
this.statsListenerCounts.clear();
+ this.workspaceLspDiagnostics.clear();
+ this.workspaceLspDiagnosticsConnectionStates.clear();
+ this.lspDiagnosticsStore.clear();
+ this.lspDiagnosticsListenerCounts.clear();
this.historyPagination.clear();
this.preReplayUsageSnapshot.clear();
this.sessionUsage.clear();
@@ -4150,6 +4421,28 @@ export function useWorkspaceStatsSnapshot(workspaceId: string): WorkspaceStatsSn
return useSyncExternalStore(subscribe, getSnapshot);
}
+export function useWorkspaceLspDiagnosticsViewState(
+ workspaceId: string
+): WorkspaceLspDiagnosticsViewState {
+ const store = getStoreInstance();
+ const subscribe = useCallback(
+ (listener: () => void) => store.subscribeLspDiagnostics(workspaceId, listener),
+ [store, workspaceId]
+ );
+ const getSnapshot = useCallback(
+ () => store.getWorkspaceLspDiagnosticsViewState(workspaceId),
+ [store, workspaceId]
+ );
+
+ return useSyncExternalStore(subscribe, getSnapshot);
+}
+
+export function useWorkspaceLspDiagnosticsSnapshot(
+ workspaceId: string
+): WorkspaceLspDiagnosticsSnapshot | null {
+ return useWorkspaceLspDiagnosticsViewState(workspaceId).snapshot;
+}
+
/**
* Hook for consumer breakdown (lazy, with tokenization).
* Updates after async Web Worker calculation completes.
diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts
index 410c5ea250..77d9ae6c60 100644
--- a/src/browser/stories/mocks/orpc.ts
+++ b/src/browser/stories/mocks/orpc.ts
@@ -59,6 +59,8 @@ import type {
CoderWorkspace,
} from "@/common/orpc/schemas/coder";
import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior";
+import { DEFAULT_LSP_PROVISIONING_MODE } from "@/common/config/schemas/appConfigOnDisk";
+import type { LspProvisioningMode } from "@/common/config/schemas/appConfigOnDisk";
import type { WorktreeArchiveBehavior } from "@/common/config/worktreeArchiveBehavior";
import type { z } from "zod";
import type { ProjectRemoveErrorSchema } from "@/common/orpc/schemas/errors";
@@ -132,6 +134,8 @@ export interface MockORPCClientOptions {
defaultRuntime?: RuntimeEnablementId | null;
/** Initial 1Password account name for config.getConfig */
onePasswordAccountName?: string | null;
+ /** Initial LSP provisioning mode for config.getConfig */
+ lspProvisioningMode?: LspProvisioningMode;
/** Initial global heartbeat default prompt for config.getConfig */
heartbeatDefaultPrompt?: string;
/** Initial global heartbeat default interval for config.getConfig */
@@ -351,6 +355,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
worktreeArchiveBehavior: initialWorktreeArchiveBehavior = "keep",
runtimeEnablement: initialRuntimeEnablement,
defaultRuntime: initialDefaultRuntime,
+ lspProvisioningMode: initialLspProvisioningMode,
onePasswordAccountName: initialOnePasswordAccountName = null,
heartbeatDefaultPrompt: initialHeartbeatDefaultPrompt,
heartbeatDefaultIntervalMs: initialHeartbeatDefaultIntervalMs,
@@ -499,6 +504,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
};
let defaultRuntime: RuntimeEnablementId | null = initialDefaultRuntime ?? null;
+ let lspProvisioningMode: LspProvisioningMode =
+ initialLspProvisioningMode ?? DEFAULT_LSP_PROVISIONING_MODE;
let onePasswordAccountName: string | null = initialOnePasswordAccountName;
let heartbeatDefaultPrompt = initialHeartbeatDefaultPrompt;
let heartbeatDefaultIntervalMs = initialHeartbeatDefaultIntervalMs;
@@ -688,6 +695,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
worktreeArchiveBehavior,
runtimeEnablement,
defaultRuntime,
+ lspProvisioningMode,
agentAiDefaults,
subagentAiDefaults,
muxGovernorUrl,
@@ -869,6 +877,12 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
notifyConfigChanged();
return Promise.resolve(undefined);
},
+ updateLspProvisioningMode: (input: { mode: LspProvisioningMode }) => {
+ lspProvisioningMode = input.mode;
+ notifyConfigChanged();
+ return Promise.resolve(undefined);
+ },
+ updateLlmDebugLogs: (_input: { enabled: boolean }) => Promise.resolve(undefined),
unenrollMuxGovernor: () => Promise.resolve(undefined),
},
agents: {
diff --git a/src/cli/run.test.ts b/src/cli/run.test.ts
index b1bbac5a68..3439e20c85 100644
--- a/src/cli/run.test.ts
+++ b/src/cli/run.test.ts
@@ -10,6 +10,8 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "path";
+import { buildEphemeralRunConfig } from "./runTrust";
+
const CLI_PATH = path.resolve(__dirname, "index.ts");
const RUN_PATH = path.resolve(__dirname, "run.ts");
@@ -146,6 +148,26 @@ describe("mux CLI", () => {
});
});
+ describe("mux run temp config", () => {
+ test("buildEphemeralRunConfig copies the effective LSP provisioning mode", () => {
+ const projectDir = "/repo";
+ const ephemeralConfig = buildEphemeralRunConfig(
+ { projects: new Map() },
+ {
+ projects: new Map([[projectDir, { workspaces: [], trusted: true }]]),
+ lspProvisioningMode: "auto",
+ },
+ projectDir,
+ "/tmp/mux-run/src"
+ );
+
+ expect(ephemeralConfig.lspProvisioningMode).toBe("auto");
+ expect(ephemeralConfig.projects).toEqual(
+ new Map([[projectDir, { workspaces: [], trusted: true }]])
+ );
+ });
+ });
+
describe("mux run", () => {
test("--help shows all options", async () => {
const result = await runCli(["run", "--help"]);
diff --git a/src/cli/run.ts b/src/cli/run.ts
index 24ff51efcf..23442f3b73 100644
--- a/src/cli/run.ts
+++ b/src/cli/run.ts
@@ -14,7 +14,7 @@ import { z } from "zod";
import * as path from "path";
import * as fs from "fs/promises";
import * as fsSync from "fs";
-import { Config, type ProjectConfig } from "../node/config";
+import { Config } from "../node/config";
import { DisposableTempDir } from "../node/services/tempDir";
import { AgentSession, type AgentSessionChatEvent } from "../node/services/agentSession";
import { CodexOauthService } from "../node/services/codexOauthService";
@@ -75,6 +75,9 @@ import { execSync } from "child_process";
import { getParseOptions } from "./argv";
import { EXPERIMENT_IDS } from "../common/constants/experiments";
import { getErrorMessage } from "@/common/utils/errors";
+import { buildEphemeralRunConfig } from "./runTrust";
+import { resolveConfiguredProjectPathForTrust } from "../node/utils/projectTrust";
+import { buildExperimentsObject } from "./runOptions";
// Display labels for CLI help (OFF, LOW, MED, HIGH, MAX).
// Deduplicate because xhigh and max both display as "MAX" for default/Anthropic
@@ -197,20 +200,6 @@ function collectExperiments(value: string, previous: string[]): string[] {
return [...previous, experimentId];
}
-/**
- * Convert experiment ID array to the experiments object expected by SendMessageOptions.
- */
-function buildExperimentsObject(experimentIds: string[]): SendMessageOptions["experiments"] {
- if (experimentIds.length === 0) return undefined;
-
- return {
- programmaticToolCalling: experimentIds.includes("programmatic-tool-calling"),
- programmaticToolCallingExclusive: experimentIds.includes("programmatic-tool-calling-exclusive"),
- system1: experimentIds.includes("system-1"),
- execSubagentHardRestart: experimentIds.includes("exec-subagent-hard-restart"),
- };
-}
-
interface MCPServerEntry {
name: string;
command: string;
@@ -337,6 +326,10 @@ async function main(): Promise {
// Create ephemeral temp dir for session data (auto-cleaned on exit)
using tempDir = new DisposableTempDir("mux-run");
+ const workspaceId = generateWorkspaceId();
+ const projectDir = path.resolve(opts.dir);
+ await ensureDirectory(projectDir);
+
// Use real config for providers, but ephemeral temp dir for session data
const realConfig = new Config();
const config = new Config(tempDir.path);
@@ -360,31 +353,19 @@ async function main(): Promise {
// Avoid importing workspace/task metadata into ephemeral CLI config because
// stale queued/running records can incorrectly throttle sub-agent tasks.
const existingConfig = realConfig.loadConfigOrDefault();
- if (existingConfig.projects.size > 0) {
- const trustOnlyProjects = new Map();
- for (const [projectPath, projectConfig] of existingConfig.projects) {
- if (projectConfig.trusted === undefined) {
- continue;
- }
-
- trustOnlyProjects.set(projectPath, {
- workspaces: [],
- trusted: projectConfig.trusted,
- });
- }
-
- if (trustOnlyProjects.size > 0) {
- await config.saveConfig({
- ...config.loadConfigOrDefault(),
- projects: trustOnlyProjects,
- });
- }
+ const ephemeralRunConfig = buildEphemeralRunConfig(
+ config.loadConfigOrDefault(),
+ existingConfig,
+ projectDir,
+ config.srcDir
+ );
+ if (
+ ephemeralRunConfig.projects.size > 0 ||
+ ephemeralRunConfig.lspProvisioningMode === "auto"
+ ) {
+ await config.saveConfig(ephemeralRunConfig);
}
- const workspaceId = generateWorkspaceId();
- const projectDir = path.resolve(opts.dir);
- await ensureDirectory(projectDir);
-
const model: string = resolveModelAlias(opts.model);
const runtimeConfig = parseRuntimeConfig(opts.runtime, config.srcDir);
// Resolve thinking: numeric indices map to the model's allowed levels (0 = lowest)
@@ -537,8 +518,15 @@ async function main(): Promise {
// Fallback to main
}
- // Read trust state from real config so trusted projects can run hooks
- const trusted = realConfig.loadConfigOrDefault().projects.get(projectDir)?.trusted ?? false;
+ // Read trust state from real config so trusted canonical projects and their known worktree
+ // paths preserve hook/LSP trust in mux run's ephemeral config.
+ const trustedProjectPath = resolveConfiguredProjectPathForTrust(existingConfig.projects, {
+ projectPath: projectDir,
+ namedWorkspacePath: projectDir,
+ });
+ const trusted = trustedProjectPath
+ ? (existingConfig.projects.get(trustedProjectPath)?.trusted ?? false)
+ : false;
const createEnv = Object.fromEntries(
Object.entries(process.env).filter(
diff --git a/src/cli/runOptions.test.ts b/src/cli/runOptions.test.ts
new file mode 100644
index 0000000000..8674e1f02a
--- /dev/null
+++ b/src/cli/runOptions.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, test } from "bun:test";
+
+import { EXPERIMENT_IDS } from "@/common/constants/experiments";
+
+import { buildExperimentsObject } from "./runOptions";
+
+describe("buildExperimentsObject", () => {
+ test("returns undefined when no CLI experiments are enabled", () => {
+ expect(buildExperimentsObject([])).toBeUndefined();
+ });
+
+ test("maps lsp-query into request-scoped send options", () => {
+ expect(buildExperimentsObject([EXPERIMENT_IDS.LSP_QUERY])).toEqual({
+ programmaticToolCalling: false,
+ programmaticToolCallingExclusive: false,
+ system1: false,
+ lspQuery: true,
+ execSubagentHardRestart: false,
+ });
+ });
+});
diff --git a/src/cli/runOptions.ts b/src/cli/runOptions.ts
new file mode 100644
index 0000000000..9b51451cc5
--- /dev/null
+++ b/src/cli/runOptions.ts
@@ -0,0 +1,20 @@
+import type { SendMessageOptions } from "../common/orpc/types";
+import { EXPERIMENT_IDS } from "../common/constants/experiments";
+
+/**
+ * Convert CLI experiment ids into the request-scoped experiment overrides used by mux run.
+ * This keeps local autonomous CLI runs aligned with the same tool gates as the desktop app.
+ */
+export function buildExperimentsObject(experimentIds: string[]): SendMessageOptions["experiments"] {
+ if (experimentIds.length === 0) return undefined;
+
+ return {
+ programmaticToolCalling: experimentIds.includes(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING),
+ programmaticToolCallingExclusive: experimentIds.includes(
+ EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE
+ ),
+ system1: experimentIds.includes(EXPERIMENT_IDS.SYSTEM_1),
+ lspQuery: experimentIds.includes(EXPERIMENT_IDS.LSP_QUERY),
+ execSubagentHardRestart: experimentIds.includes(EXPERIMENT_IDS.EXEC_SUBAGENT_HARD_RESTART),
+ };
+}
diff --git a/src/cli/runTrust.test.ts b/src/cli/runTrust.test.ts
new file mode 100644
index 0000000000..e8f3fcefb8
--- /dev/null
+++ b/src/cli/runTrust.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, test } from "bun:test";
+import type { ProjectConfig } from "@/node/config";
+import { buildTrustOnlyProjectsForRun } from "./runTrust";
+
+const CANONICAL_PROJECT_PATH = "/Users/tester/src/mux";
+const SRC_DIR = "/Users/tester/.mux/src";
+const WORKTREE_PROJECT_PATH = `${SRC_DIR}/mux`;
+const KNOWN_WORKTREE_PATH = `${WORKTREE_PROJECT_PATH}/lsp-rb97`;
+const UNKNOWN_WORKTREE_PATH = `${WORKTREE_PROJECT_PATH}/not-configured`;
+
+function createProjectConfig(overrides: Partial = {}): ProjectConfig {
+ return {
+ workspaces: [],
+ ...overrides,
+ };
+}
+
+describe("buildTrustOnlyProjectsForRun", () => {
+ test("preserves canonical project trust without workspace metadata", () => {
+ const projects = new Map([
+ [CANONICAL_PROJECT_PATH, createProjectConfig({ trusted: true })],
+ ]);
+
+ const trustOnlyProjects = buildTrustOnlyProjectsForRun(
+ projects,
+ CANONICAL_PROJECT_PATH,
+ SRC_DIR
+ );
+
+ expect(trustOnlyProjects).toEqual(
+ new Map([[CANONICAL_PROJECT_PATH, { workspaces: [], trusted: true }]])
+ );
+ });
+
+ test("adds an exact trust alias for a known worktree path", () => {
+ const projects = new Map([
+ [
+ CANONICAL_PROJECT_PATH,
+ createProjectConfig({
+ trusted: true,
+ workspaces: [{ path: KNOWN_WORKTREE_PATH }],
+ }),
+ ],
+ ]);
+
+ const trustOnlyProjects = buildTrustOnlyProjectsForRun(projects, KNOWN_WORKTREE_PATH, SRC_DIR);
+
+ expect(trustOnlyProjects).toEqual(
+ new Map([
+ [CANONICAL_PROJECT_PATH, { workspaces: [], trusted: true }],
+ [WORKTREE_PROJECT_PATH, { workspaces: [], trusted: true }],
+ ])
+ );
+ });
+
+ test("does not trust arbitrary unconfigured worktree-like paths", () => {
+ const projects = new Map([
+ [
+ CANONICAL_PROJECT_PATH,
+ createProjectConfig({
+ trusted: true,
+ workspaces: [{ path: KNOWN_WORKTREE_PATH }],
+ }),
+ ],
+ ]);
+
+ const trustOnlyProjects = buildTrustOnlyProjectsForRun(
+ projects,
+ UNKNOWN_WORKTREE_PATH,
+ SRC_DIR
+ );
+
+ expect(trustOnlyProjects.has(UNKNOWN_WORKTREE_PATH)).toBe(false);
+ expect(trustOnlyProjects.has(WORKTREE_PROJECT_PATH)).toBe(false);
+ expect(trustOnlyProjects).toEqual(
+ new Map([[CANONICAL_PROJECT_PATH, { workspaces: [], trusted: true }]])
+ );
+ });
+});
diff --git a/src/cli/runTrust.ts b/src/cli/runTrust.ts
new file mode 100644
index 0000000000..e1623c8da8
--- /dev/null
+++ b/src/cli/runTrust.ts
@@ -0,0 +1,88 @@
+import type { ProjectConfig, ProjectsConfig } from "@/node/config";
+import * as path from "path";
+import { resolveConfiguredProjectPathForTrust } from "@/node/utils/projectTrust";
+import { stripTrailingSlashes } from "@/node/utils/pathUtils";
+
+function toTrustOnlyProjectConfig(projectConfig: ProjectConfig): ProjectConfig | undefined {
+ if (projectConfig.trusted === undefined) {
+ return undefined;
+ }
+
+ return {
+ workspaces: [],
+ trusted: projectConfig.trusted,
+ };
+}
+
+function deriveMuxRunProjectPath(projectDir: string, srcDir: string): string {
+ const normalizedProjectDir = stripTrailingSlashes(projectDir);
+ const normalizedSrcDir = stripTrailingSlashes(srcDir);
+
+ if (normalizedProjectDir.startsWith(`${normalizedSrcDir}${path.sep}`)) {
+ // Match AgentSession.ensureMetadata(): worktree-backed mux run sessions persist the
+ // parent src bucket as projectPath rather than the specific checkout directory.
+ return stripTrailingSlashes(path.dirname(normalizedProjectDir));
+ }
+
+ return normalizedProjectDir;
+}
+
+export function buildEphemeralRunConfig(
+ tempConfig: ProjectsConfig,
+ existingConfig: ProjectsConfig,
+ projectDir: string,
+ srcDir: string
+): ProjectsConfig {
+ return {
+ ...tempConfig,
+ // Keep mux run aligned with the desktop app's effective LSP provisioning mode,
+ // including read-time env overrides, without importing unrelated workspace/task state.
+ lspProvisioningMode: existingConfig.lspProvisioningMode,
+ projects: buildTrustOnlyProjectsForRun(existingConfig.projects, projectDir, srcDir),
+ };
+}
+
+export function buildTrustOnlyProjectsForRun(
+ projects: ProjectsConfig["projects"],
+ projectDir: string,
+ srcDir: string
+): Map {
+ const trustOnlyProjects = new Map();
+
+ for (const [projectPath, projectConfig] of projects) {
+ const trustOnlyProjectConfig = toTrustOnlyProjectConfig(projectConfig);
+ if (!trustOnlyProjectConfig) {
+ continue;
+ }
+
+ trustOnlyProjects.set(projectPath, trustOnlyProjectConfig);
+ }
+
+ const normalizedProjectDir = stripTrailingSlashes(projectDir);
+ const resolvedProjectPath = resolveConfiguredProjectPathForTrust(projects, {
+ projectPath: normalizedProjectDir,
+ namedWorkspacePath: normalizedProjectDir,
+ });
+ if (!resolvedProjectPath) {
+ return trustOnlyProjects;
+ }
+
+ const resolvedProjectConfig = trustOnlyProjects.get(resolvedProjectPath);
+ if (!resolvedProjectConfig) {
+ return trustOnlyProjects;
+ }
+
+ const muxRunProjectPath =
+ resolvedProjectPath === normalizedProjectDir
+ ? normalizedProjectDir
+ : deriveMuxRunProjectPath(normalizedProjectDir, srcDir);
+
+ // Preserve trust for the exact projectPath key that mux run will write into session metadata,
+ // without importing stored workspace/task records into the ephemeral CLI config.
+ trustOnlyProjects.set(muxRunProjectPath, {
+ workspaces: [],
+ trusted: resolvedProjectConfig.trusted,
+ });
+
+ return trustOnlyProjects;
+}
diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts
index bdac7bfa21..571f48a74a 100644
--- a/src/cli/server.test.ts
+++ b/src/cli/server.test.ts
@@ -26,12 +26,15 @@ import { ServiceContainer } from "@/node/services/serviceContainer";
import type { RouterClient } from "@orpc/server";
import { createOrpcServer, type OrpcServer } from "@/node/orpc/server";
import type { ProjectConfig } from "@/common/types/project";
+import type { WorkspaceLspDiagnosticsSnapshot } from "@/common/orpc/types";
+import type { LspFileDiagnostics } from "@/node/services/lsp/types";
import { shouldExposeLaunchProject } from "@/cli/launchProject";
// --- Test Server Factory ---
interface TestServerHandle {
server: OrpcServer;
+ services: ServiceContainer;
tempDir: string;
close: () => Promise;
}
@@ -72,9 +75,12 @@ async function createTestServer(): Promise {
return {
server,
+ services,
tempDir,
close: async () => {
await server.close();
+ await services.dispose();
+ await services.shutdown();
// Cleanup temp directory
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
},
@@ -124,6 +130,22 @@ function createProjectConfig(projectKind?: ProjectConfig["projectKind"]): Projec
};
}
+function setWorkspaceLspDiagnostics(
+ services: ServiceContainer,
+ snapshot: WorkspaceLspDiagnosticsSnapshot
+): void {
+ const manager = services.lspManager as unknown as {
+ workspaceDiagnostics: Map>>;
+ notifyWorkspaceDiagnosticsListeners: (workspaceId: string) => void;
+ };
+ const diagnosticsByUri = new Map();
+ for (const diagnostics of snapshot.diagnostics) {
+ diagnosticsByUri.set(diagnostics.uri, diagnostics);
+ }
+ manager.workspaceDiagnostics.set(snapshot.workspaceId, new Map([["typescript:file:///workspace", diagnosticsByUri]]));
+ manager.notifyWorkspaceDiagnosticsListeners(snapshot.workspaceId);
+}
+
describe("shouldExposeLaunchProject", () => {
test("returns true when only system projects exist", () => {
const projects: Array<[string, ProjectConfig]> = [
@@ -271,6 +293,38 @@ describe("oRPC Server Endpoints", () => {
expect(ticks).toHaveLength(1);
expect(ticks[0].tick).toBe(1);
});
+
+ test("workspace.lsp.listDiagnostics returns the current workspace snapshot", async () => {
+ const client = createHttpClient(serverHandle.server.baseUrl);
+ setWorkspaceLspDiagnostics(serverHandle.services, {
+ workspaceId: "lsp-http-workspace",
+ diagnostics: [
+ {
+ uri: "file:///workspace/src/example.ts",
+ path: "/workspace/src/example.ts",
+ serverId: "typescript",
+ rootUri: "file:///workspace",
+ version: 1,
+ diagnostics: [
+ {
+ range: {
+ start: { line: 1, character: 1 },
+ end: { line: 1, character: 5 },
+ },
+ severity: 1,
+ source: "tsserver",
+ message: "http snapshot",
+ },
+ ],
+ receivedAtMs: 1,
+ },
+ ],
+ });
+
+ const result = await client.workspace.lsp.listDiagnostics({ workspaceId: "lsp-http-workspace" });
+ expect(result.workspaceId).toBe("lsp-http-workspace");
+ expect(result.diagnostics[0]?.diagnostics[0]?.message).toBe("http snapshot");
+ });
});
describe("WebSocket endpoint (/orpc/ws)", () => {
@@ -351,6 +405,52 @@ describe("oRPC Server Endpoints", () => {
close();
}
});
+
+ test("workspace.lsp.subscribeDiagnostics emits initial and subsequent snapshots", async () => {
+ const workspaceId = "lsp-ws-workspace";
+ const { client, close } = await createWebSocketClient(serverHandle.server.wsUrl);
+ try {
+ const stream = await client.workspace.lsp.subscribeDiagnostics({ workspaceId });
+ const initial =
+ (await stream.next()) as IteratorResult;
+ expect(initial.done).toBe(false);
+ expect(initial.value?.workspaceId).toBe(workspaceId);
+ expect(initial.value?.diagnostics).toEqual([]);
+
+ setWorkspaceLspDiagnostics(serverHandle.services, {
+ workspaceId,
+ diagnostics: [
+ {
+ uri: "file:///workspace/src/example.ts",
+ path: "/workspace/src/example.ts",
+ serverId: "typescript",
+ rootUri: "file:///workspace",
+ version: 2,
+ diagnostics: [
+ {
+ range: {
+ start: { line: 2, character: 1 },
+ end: { line: 2, character: 5 },
+ },
+ severity: 1,
+ source: "tsserver",
+ message: "ws snapshot",
+ },
+ ],
+ receivedAtMs: 2,
+ },
+ ],
+ });
+
+ const update =
+ (await stream.next()) as IteratorResult;
+ expect(update.done).toBe(false);
+ expect(update.value?.diagnostics[0]?.diagnostics[0]?.message).toBe("ws snapshot");
+ await stream.return?.();
+ } finally {
+ close();
+ }
+ });
});
describe("Cross-transport consistency", () => {
diff --git a/src/common/config/schemas/appConfigOnDisk.test.ts b/src/common/config/schemas/appConfigOnDisk.test.ts
index 707585efd6..0842a4fabd 100644
--- a/src/common/config/schemas/appConfigOnDisk.test.ts
+++ b/src/common/config/schemas/appConfigOnDisk.test.ts
@@ -48,6 +48,14 @@ describe("AppConfigOnDiskSchema", () => {
);
});
+ it("validates lspProvisioningMode values", () => {
+ expect(AppConfigOnDiskSchema.safeParse({ lspProvisioningMode: "manual" }).success).toBe(true);
+ expect(AppConfigOnDiskSchema.safeParse({ lspProvisioningMode: "auto" }).success).toBe(true);
+ expect(AppConfigOnDiskSchema.safeParse({ lspProvisioningMode: "semi-auto" }).success).toBe(
+ false
+ );
+ });
+
it("rejects runtimeEnablement values other than false", () => {
expect(AppConfigOnDiskSchema.safeParse({ runtimeEnablement: { ssh: true } }).success).toBe(
false
diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts
index a33e4d8a4b..e3941443b4 100644
--- a/src/common/config/schemas/appConfigOnDisk.ts
+++ b/src/common/config/schemas/appConfigOnDisk.ts
@@ -34,6 +34,10 @@ export const FeatureFlagOverrideSchema = z.enum(["default", "on", "off"]);
export const UpdateChannelSchema = z.enum(["stable", "nightly"]);
+export const LSP_PROVISIONING_MODES = ["manual", "auto"] as const;
+export const DEFAULT_LSP_PROVISIONING_MODE = "manual" satisfies (typeof LSP_PROVISIONING_MODES)[number];
+export const LspProvisioningModeSchema = z.enum(LSP_PROVISIONING_MODES);
+
export const AppConfigOnDiskSchema = z
.object({
projects: z.array(z.tuple([z.string(), ProjectConfigSchema])).optional(),
@@ -81,6 +85,7 @@ export const AppConfigOnDiskSchema = z
updateChannel: UpdateChannelSchema.optional(),
runtimeEnablement: RuntimeEnablementOverridesSchema.optional(),
defaultRuntime: RuntimeEnablementIdSchema.optional(),
+ lspProvisioningMode: LspProvisioningModeSchema.optional(),
onePasswordAccountName: z.string().optional(),
})
.passthrough();
@@ -91,5 +96,6 @@ export type SubagentAiDefaultsEntry = z.infer;
export type FeatureFlagOverride = z.infer;
export type UpdateChannel = z.infer;
+export type LspProvisioningMode = z.infer;
export type AppConfigOnDisk = z.infer;
diff --git a/src/common/constants/experiments.ts b/src/common/constants/experiments.ts
index 80dc32e5dd..3cf9f3e0b8 100644
--- a/src/common/constants/experiments.ts
+++ b/src/common/constants/experiments.ts
@@ -10,6 +10,7 @@ export const EXPERIMENT_IDS = {
PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE: "programmatic-tool-calling-exclusive",
CONFIGURABLE_BIND_URL: "configurable-bind-url",
SYSTEM_1: "system-1",
+ LSP_QUERY: "lsp-query",
EXEC_SUBAGENT_HARD_RESTART: "exec-subagent-hard-restart",
MUX_GOVERNOR: "mux-governor",
MULTI_PROJECT_WORKSPACES: "multi-project-workspaces",
@@ -82,6 +83,15 @@ export const EXPERIMENTS: Record = {
userOverridable: true,
showInSettings: true,
},
+ [EXPERIMENT_IDS.LSP_QUERY]: {
+ id: EXPERIMENT_IDS.LSP_QUERY,
+ name: "LSP Query Tool",
+ description:
+ "Enable the built-in lsp_query tool for definitions, references, hover, and symbol lookup",
+ enabledByDefault: false,
+ userOverridable: true,
+ showInSettings: true,
+ },
[EXPERIMENT_IDS.EXEC_SUBAGENT_HARD_RESTART]: {
id: EXPERIMENT_IDS.EXEC_SUBAGENT_HARD_RESTART,
name: "Exec sub-agent hard restart",
diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts
index 181a5e939d..65cbce7c8d 100644
--- a/src/common/orpc/schemas.ts
+++ b/src/common/orpc/schemas.ts
@@ -39,6 +39,22 @@ export {
WorkspaceStatsSnapshotSchema,
} from "./schemas/workspaceStats";
+// Workspace LSP diagnostics schemas
+export {
+ LspDiagnosticSchema,
+ LspFileDiagnosticsSchema,
+ LspPositionSchema,
+ LspRangeSchema,
+ WorkspaceLspDiagnosticsSnapshotSchema,
+} from "./schemas/workspaceLsp";
+export type {
+ LspDiagnostic,
+ LspFileDiagnostics,
+ LspPosition,
+ LspRange,
+ WorkspaceLspDiagnosticsSnapshot,
+} from "./schemas/workspaceLsp";
+
// Analytics schemas
export {
AgentCostRowSchema,
diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts
index 01a3c55816..d80c6210cd 100644
--- a/src/common/orpc/schemas/api.ts
+++ b/src/common/orpc/schemas/api.ts
@@ -39,6 +39,7 @@ import {
} from "./terminal";
import { BashToolResultSchema, FileTreeNodeSchema } from "./tools";
import { WorkspaceStatsSnapshotSchema } from "./workspaceStats";
+import { WorkspaceLspDiagnosticsSnapshotSchema } from "./workspaceLsp";
import {
FrontendWorkspaceMetadataSchema,
GitStatusSchema,
@@ -78,6 +79,7 @@ import { PolicyGetResponseSchema } from "./policy";
import {
AgentAiDefaultsSchema,
SubagentAiDefaultsSchema,
+ LspProvisioningModeSchema,
UpdateChannelSchema,
} from "../../config/schemas/appConfigOnDisk";
import {
@@ -1400,6 +1402,16 @@ export const workspace = {
output: ResultSchema(z.void(), z.string()),
},
},
+ lsp: {
+ listDiagnostics: {
+ input: z.object({ workspaceId: z.string() }),
+ output: WorkspaceLspDiagnosticsSnapshotSchema,
+ },
+ subscribeDiagnostics: {
+ input: z.object({ workspaceId: z.string() }),
+ output: eventIterator(WorkspaceLspDiagnosticsSnapshotSchema),
+ },
+ },
getSessionUsage: {
input: z.object({ workspaceId: z.string() }),
output: SessionUsageFileSchema.optional(),
@@ -1765,6 +1777,7 @@ export const config = {
worktreeArchiveBehavior: z.enum(WORKTREE_ARCHIVE_BEHAVIORS),
runtimeEnablement: z.record(z.string(), z.boolean()),
defaultRuntime: z.string().nullable(),
+ lspProvisioningMode: LspProvisioningModeSchema,
agentAiDefaults: AgentAiDefaultsSchema,
// Legacy fields (downgrade compatibility)
subagentAiDefaults: SubagentAiDefaultsSchema,
@@ -1849,6 +1862,14 @@ export const config = {
.strict(),
output: z.void(),
},
+ updateLspProvisioningMode: {
+ input: z
+ .object({
+ mode: LspProvisioningModeSchema,
+ })
+ .strict(),
+ output: z.void(),
+ },
updateLlmDebugLogs: {
input: z
.object({
diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts
index fd41785408..472f7ba547 100644
--- a/src/common/orpc/schemas/stream.ts
+++ b/src/common/orpc/schemas/stream.ts
@@ -644,6 +644,7 @@ export const ExperimentsSchema = z.object({
programmaticToolCallingExclusive: z.boolean().optional(),
advisorTool: z.boolean().optional(),
system1: z.boolean().optional(),
+ lspQuery: z.boolean().optional(),
execSubagentHardRestart: z.boolean().optional(),
});
diff --git a/src/common/orpc/schemas/workspaceLsp.ts b/src/common/orpc/schemas/workspaceLsp.ts
new file mode 100644
index 0000000000..983c3a9d07
--- /dev/null
+++ b/src/common/orpc/schemas/workspaceLsp.ts
@@ -0,0 +1,40 @@
+import { z } from "zod";
+
+export const LspPositionSchema = z.object({
+ line: z.number(),
+ character: z.number(),
+});
+
+export const LspRangeSchema = z.object({
+ start: LspPositionSchema,
+ end: LspPositionSchema,
+});
+
+export const LspDiagnosticSchema = z.object({
+ range: LspRangeSchema,
+ severity: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).optional(),
+ code: z.union([z.string(), z.number()]).optional(),
+ source: z.string().optional(),
+ message: z.string(),
+});
+
+export const LspFileDiagnosticsSchema = z.object({
+ uri: z.string(),
+ path: z.string(),
+ serverId: z.string(),
+ rootUri: z.string(),
+ version: z.number().optional(),
+ diagnostics: z.array(LspDiagnosticSchema),
+ receivedAtMs: z.number(),
+});
+
+export const WorkspaceLspDiagnosticsSnapshotSchema = z.object({
+ workspaceId: z.string(),
+ diagnostics: z.array(LspFileDiagnosticsSchema),
+});
+
+export type LspPosition = z.infer;
+export type LspRange = z.infer;
+export type LspDiagnostic = z.infer;
+export type LspFileDiagnostics = z.infer;
+export type WorkspaceLspDiagnosticsSnapshot = z.infer;
diff --git a/src/common/orpc/types.ts b/src/common/orpc/types.ts
index c5ddc7648a..65e2c1b053 100644
--- a/src/common/orpc/types.ts
+++ b/src/common/orpc/types.ts
@@ -47,6 +47,13 @@ export type UpdateStatus = z.infer;
export type DesktopPrereqStatus = z.infer;
export type ChatMuxMessage = z.infer;
export type WorkspaceStatsSnapshot = z.infer;
+export type LspPosition = z.infer;
+export type LspRange = z.infer;
+export type LspDiagnostic = z.infer;
+export type LspFileDiagnostics = z.infer;
+export type WorkspaceLspDiagnosticsSnapshot = z.infer<
+ typeof schemas.WorkspaceLspDiagnosticsSnapshotSchema
+>;
export type WorkspaceActivitySnapshot = z.infer;
export type FrontendWorkspaceMetadataSchemaType = z.infer<
typeof schemas.FrontendWorkspaceMetadataSchema
diff --git a/src/common/types/project.ts b/src/common/types/project.ts
index 4236ad7844..adc529eb9f 100644
--- a/src/common/types/project.ts
+++ b/src/common/types/project.ts
@@ -5,7 +5,11 @@
import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior";
import type { WorktreeArchiveBehavior } from "@/common/config/worktreeArchiveBehavior";
-import type { FeatureFlagOverride, UpdateChannel } from "@/common/config/schemas/appConfigOnDisk";
+import type {
+ FeatureFlagOverride,
+ LspProvisioningMode,
+ UpdateChannel,
+} from "@/common/config/schemas/appConfigOnDisk";
import type { z } from "zod";
import type {
ProjectConfigSchema,
@@ -24,7 +28,7 @@ export type SectionConfig = z.infer;
export type ProjectConfig = z.infer;
-export type { FeatureFlagOverride, UpdateChannel };
+export type { FeatureFlagOverride, LspProvisioningMode, UpdateChannel };
export interface ProjectsConfig {
projects: Map;
@@ -161,6 +165,14 @@ export interface ProjectsConfig {
/** Global default runtime for new workspaces. */
defaultRuntime?: RuntimeEnablementId;
+ /**
+ * How mux should provision built-in LSP servers when they are not already available.
+ *
+ * - `"manual"`: use only trusted workspace-local binaries/modules and PATH-installed tools.
+ * - `"auto"`: also allow package-manager execution and managed installs for supported servers.
+ */
+ lspProvisioningMode?: LspProvisioningMode;
+
/**
* Override the default shell for local integrated terminals.
*
diff --git a/src/common/types/tools.ts b/src/common/types/tools.ts
index e7db14f007..2ba54ddd4e 100644
--- a/src/common/types/tools.ts
+++ b/src/common/types/tools.ts
@@ -22,6 +22,7 @@ import type {
MuxAgentsReadToolResultSchema,
MuxAgentsWriteToolResultSchema,
FileReadToolResultSchema,
+ LspQueryToolResultSchema,
AttachFileToolResultSchema,
TaskToolResultSchema,
TaskAwaitToolResultSchema,
@@ -127,6 +128,9 @@ export interface ToolOutputUiOnlyFields {
// FileReadToolResult derived from Zod schema (single source of truth)
export type FileReadToolResult = z.infer;
+export type LspQueryToolArgs = z.infer;
+export type LspQueryToolResult = z.infer;
+
// AttachFileToolResult derived from Zod schema (single source of truth)
export type AttachFileToolResult = z.infer;
diff --git a/src/common/utils/tools/toolAvailability.ts b/src/common/utils/tools/toolAvailability.ts
index 5088280031..2326f94a15 100644
--- a/src/common/utils/tools/toolAvailability.ts
+++ b/src/common/utils/tools/toolAvailability.ts
@@ -1,6 +1,7 @@
export interface ToolAvailabilityContext {
workspaceId: string;
parentWorkspaceId?: string | null;
+ enableLspQuery?: boolean;
}
/**
@@ -10,6 +11,7 @@ export interface ToolAvailabilityContext {
export function getToolAvailabilityOptions(context: ToolAvailabilityContext) {
return {
enableAgentReport: Boolean(context.parentWorkspaceId),
+ enableLspQuery: context.enableLspQuery === true,
// skills_catalog_* tools are always available; agent tool policy controls access.
} as const;
}
diff --git a/src/common/utils/tools/toolDefinitions.test.ts b/src/common/utils/tools/toolDefinitions.test.ts
index 1bc13b6183..182e531de3 100644
--- a/src/common/utils/tools/toolDefinitions.test.ts
+++ b/src/common/utils/tools/toolDefinitions.test.ts
@@ -2,6 +2,7 @@ import { RUNTIME_MODE } from "@/common/types/runtime";
import {
buildTaskToolDescription,
getAvailableTools,
+ LspQueryToolResultSchema,
TaskToolArgsSchema,
TOOL_DEFINITIONS,
} from "./toolDefinitions";
@@ -238,6 +239,58 @@ describe("TOOL_DEFINITIONS", () => {
}
);
+ it("accepts enriched single-root workspace symbol results", () => {
+ const parsed = LspQueryToolResultSchema.safeParse({
+ success: true,
+ operation: "workspace_symbols",
+ serverId: "typescript",
+ rootUri: "file:///workspace",
+ symbols: [
+ {
+ name: "ResourceService",
+ kind: 5,
+ kindLabel: "Class",
+ path: "/workspace/src/resource.ts",
+ uri: "file:///workspace/src/resource.ts",
+ range: {
+ start: { line: 1, character: 1 },
+ end: { line: 1, character: 16 },
+ },
+ exportInfo: {
+ isExported: true,
+ confidence: "heuristic",
+ evidence: "Found an export keyword near the declaration",
+ },
+ },
+ ],
+ });
+
+ expect(parsed.success).toBe(true);
+ });
+
+ it("accepts directory workspace symbol results with skipped roots metadata", () => {
+ const parsed = LspQueryToolResultSchema.safeParse({
+ success: true,
+ operation: "workspace_symbols",
+ results: [],
+ skippedRoots: [
+ {
+ serverId: "rust",
+ rootUri: "file:///workspace",
+ reasonCode: "unsupported_provisioning",
+ reason:
+ "rust-analyzer is not available on PATH and automatic installation is not supported yet",
+ installGuidance:
+ "Install rust-analyzer and ensure it is available on PATH, or query a representative source file for a supported language.",
+ },
+ ],
+ disambiguationHint:
+ "Multiple LSP roots returned workspace symbol matches. Compare serverId, rootUri, uri, and kindLabel before choosing.",
+ });
+
+ expect(parsed.success).toBe(true);
+ });
+
it("asks for clarification via ask_user_question (instead of emitting open questions)", () => {
expect(TOOL_DEFINITIONS.ask_user_question.description).toContain(
"MUST be used when you need user clarification"
diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts
index 35ecacbc1f..b8dc1255d0 100644
--- a/src/common/utils/tools/toolDefinitions.ts
+++ b/src/common/utils/tools/toolDefinitions.ts
@@ -982,6 +982,51 @@ export const TOOL_DEFINITIONS = {
})
),
},
+ lsp_query: {
+ description:
+ "Query the built-in language server for code intelligence. " +
+ "Use this for hover, definitions, references, implementations, and symbol lookup. " +
+ "Provide line and column using 1-based positions for hover/definition/reference/implementation. " +
+ "For workspace_symbols, provide a non-empty query plus either a representative source file path or a directory/relative path; directory queries return non-empty result items per usable LSP root and may include skippedRoots guidance for unavailable roots.",
+ schema: z.preprocess(
+ normalizeFilePath,
+ z
+ .object({
+ operation: z.enum([
+ "hover",
+ "definition",
+ "references",
+ "implementation",
+ "document_symbols",
+ "workspace_symbols",
+ ]),
+ path: FILE_TOOL_PATH.describe(
+ "Path to the file being queried (absolute or relative to the current workspace)"
+ ),
+ line: z
+ .number()
+ .int()
+ .positive()
+ .nullish()
+ .describe("1-based line number for position-based queries"),
+ column: z
+ .number()
+ .int()
+ .positive()
+ .nullish()
+ .describe("1-based column number for position-based queries"),
+ query: z
+ .string()
+ .nullish()
+ .describe("Required for workspace_symbols; ignored by other operations"),
+ includeDeclaration: z
+ .boolean()
+ .nullish()
+ .describe("For references only: whether declarations should be included"),
+ })
+ .strict()
+ ),
+ },
attach_file: {
description:
"Attach a supported file from the filesystem so later model steps receive it as a real attachment instead of a huge base64 JSON blob. " +
@@ -1888,6 +1933,109 @@ export const FileReadToolResultSchema = z.union([
}),
]);
+const LspQueryResultRangeSchema = z
+ .object({
+ start: z.object({
+ line: z.number().int().positive(),
+ character: z.number().int().positive(),
+ }),
+ end: z.object({
+ line: z.number().int().positive(),
+ character: z.number().int().positive(),
+ }),
+ })
+ .strict();
+
+const LspQueryLocationSchema = z
+ .object({
+ path: z.string(),
+ uri: z.string(),
+ range: LspQueryResultRangeSchema,
+ preview: z.string().optional(),
+ })
+ .strict();
+
+const LspQuerySymbolExportInfoSchema = z
+ .object({
+ isExported: z.boolean(),
+ confidence: z.literal("heuristic"),
+ evidence: z.string().optional(),
+ })
+ .strict();
+
+const LspQuerySymbolSchema = z
+ .object({
+ name: z.string(),
+ kind: z.number().int(),
+ kindLabel: z.string(),
+ detail: z.string().optional(),
+ containerName: z.string().optional(),
+ path: z.string(),
+ uri: z.string(),
+ range: LspQueryResultRangeSchema,
+ preview: z.string().optional(),
+ exportInfo: LspQuerySymbolExportInfoSchema.optional(),
+ })
+ .strict();
+
+const LspQueryWorkspaceSymbolsSkippedRootSchema = z
+ .object({
+ serverId: z.string(),
+ rootUri: z.string(),
+ reasonCode: z.enum(["missing_binary", "unsupported_provisioning", "query_failed"]),
+ reason: z.string(),
+ installGuidance: z.string().optional(),
+ })
+ .strict();
+
+const LspQueryWorkspaceSymbolsRootSchema = z
+ .object({
+ serverId: z.string(),
+ rootUri: z.string(),
+ symbols: z.array(LspQuerySymbolSchema),
+ warning: z.string().optional(),
+ })
+ .strict();
+
+export const LspQueryToolResultSchema = z.union([
+ z
+ .object({
+ success: z.literal(true),
+ operation: z.enum([
+ "hover",
+ "definition",
+ "references",
+ "implementation",
+ "document_symbols",
+ "workspace_symbols",
+ ]),
+ serverId: z.string(),
+ rootUri: z.string(),
+ hover: z.string().optional(),
+ locations: z.array(LspQueryLocationSchema).optional(),
+ symbols: z.array(LspQuerySymbolSchema).optional(),
+ warning: z.string().optional(),
+ })
+ .strict(),
+ z
+ .object({
+ success: z.literal(true),
+ operation: z.literal("workspace_symbols"),
+ results: z.array(LspQueryWorkspaceSymbolsRootSchema),
+ skippedRoots: z.array(LspQueryWorkspaceSymbolsSkippedRootSchema).optional(),
+ disambiguationHint: z.string().optional(),
+ warning: z.string().optional(),
+ })
+ .strict(),
+ z
+ .object({
+ success: z.literal(false),
+ error: z.string(),
+ warning: z.string().optional(),
+ })
+ .strict(),
+]);
+
const AttachFileToolTextPartSchema = z
.object({
type: z.literal("text"),
@@ -2011,6 +2159,7 @@ export type BridgeableToolName =
| "bash_background_list"
| "bash_background_terminate"
| "file_read"
+ | "lsp_query"
| "attach_file"
| "agent_skill_read"
| "agent_skill_read_file"
@@ -2038,6 +2187,7 @@ export const RESULT_SCHEMAS: Record = {
bash_background_list: BashBackgroundListResultSchema,
bash_background_terminate: BashBackgroundTerminateResultSchema,
file_read: FileReadToolResultSchema,
+ lsp_query: LspQueryToolResultSchema,
attach_file: AttachFileToolResultSchema,
agent_skill_read: AgentSkillReadToolResultSchema,
agent_skill_read_file: AgentSkillReadFileToolResultSchema,
@@ -2081,6 +2231,7 @@ export function getAvailableTools(
options?: {
enableAgentReport?: boolean;
enableAnalyticsQuery?: boolean;
+ enableLspQuery?: boolean;
enableAdvisor?: boolean;
/** @deprecated Mux global tools are always included. */
enableMuxGlobalAgentsTools?: boolean;
@@ -2089,6 +2240,7 @@ export function getAvailableTools(
const [provider] = modelString.split(":");
const enableAgentReport = options?.enableAgentReport ?? true;
const enableAnalyticsQuery = options?.enableAnalyticsQuery ?? true;
+ const enableLspQuery = options?.enableLspQuery ?? false;
const enableAdvisor = options?.enableAdvisor ?? false;
// Base tools available for all models
@@ -2116,6 +2268,7 @@ export function getAvailableTools(
"agent_skill_read",
"agent_skill_read_file",
"file_edit_replace_string",
+ ...(enableLspQuery ? ["lsp_query"] : []),
// "file_edit_replace_lines", // DISABLED: causes models to break repo state
"file_edit_insert",
...(enableAdvisor ? ["advisor"] : []),
diff --git a/src/common/utils/tools/tools.test.ts b/src/common/utils/tools/tools.test.ts
index 1a630f4b37..dc673917de 100644
--- a/src/common/utils/tools/tools.test.ts
+++ b/src/common/utils/tools/tools.test.ts
@@ -4,6 +4,7 @@ import { z } from "zod";
import type { InitStateManager } from "@/node/services/initStateManager";
import type { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager";
import { LocalRuntime } from "@/node/runtime/LocalRuntime";
+import { LspManager } from "@/node/services/lsp/lspManager";
import { getToolsForModel } from "./tools";
const DESKTOP_TOOL_NAMES = [
@@ -158,6 +159,55 @@ describe("getToolsForModel", () => {
expect(Object.keys(tools).filter((toolName) => toolName.startsWith("desktop_"))).toEqual([]);
});
+ test("only includes lsp_query when the experiment is enabled and a manager is available", async () => {
+ const runtime = new LocalRuntime(process.cwd());
+ const initStateManager = createInitStateManager();
+ const lspManager = new LspManager({ registry: [] });
+ lspManager.query = mock(() =>
+ Promise.resolve({
+ operation: "hover" as const,
+ serverId: "typescript",
+ rootUri: "file:///tmp/workspace",
+ hover: "",
+ })
+ );
+
+ try {
+ const toolsWithoutLsp = await getToolsForModel(
+ "noop:model",
+ {
+ cwd: process.cwd(),
+ runtime,
+ runtimeTempDir: "/tmp",
+ lspQueryEnabled: false,
+ },
+ "ws-1",
+ initStateManager
+ );
+ expect(toolsWithoutLsp.lsp_query).toBeUndefined();
+
+ const toolsWithLsp = await getToolsForModel(
+ "noop:model",
+ {
+ cwd: process.cwd(),
+ runtime,
+ runtimeTempDir: "/tmp",
+ lspManager,
+ lspPolicyContext: {
+ provisioningMode: "manual",
+ trustedWorkspaceExecution: true,
+ },
+ lspQueryEnabled: true,
+ },
+ "ws-1",
+ initStateManager
+ );
+ expect(toolsWithLsp.lsp_query).toBeDefined();
+ } finally {
+ await lspManager.dispose();
+ }
+ });
+
test("returns tool keys in sorted order", async () => {
const runtime = new LocalRuntime(process.cwd());
const initStateManager = createInitStateManager();
diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts
index ad47df4779..9f2fa98f9e 100644
--- a/src/common/utils/tools/tools.ts
+++ b/src/common/utils/tools/tools.ts
@@ -17,6 +17,7 @@ import { createTodoWriteTool, createTodoReadTool } from "@/node/services/tools/t
import { createNotifyTool } from "@/node/services/tools/notify";
import { createAnalyticsQueryTool } from "@/node/services/tools/analyticsQuery";
import { createDesktopTools } from "@/node/services/tools/desktopTools";
+import { createLspQueryTool } from "@/node/services/tools/lsp_query";
import type { MuxToolScope } from "@/common/types/toolScope";
import { createTaskTool } from "@/node/services/tools/task";
import { createTaskApplyGitPatchTool } from "@/node/services/tools/task_apply_git_patch";
@@ -57,6 +58,8 @@ import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition";
import type { AgentSkillDescriptor } from "@/common/types/agentSkill";
import type { ModelMessage } from "@/common/types/message";
import type { ProjectRef } from "@/common/types/workspace";
+import type { LspManager } from "@/node/services/lsp/lspManager";
+import type { LspPolicyContext } from "@/node/services/lsp/types";
export interface ToolModelUsageEvent {
source: "tool";
@@ -123,6 +126,8 @@ export interface ToolConfiguration {
muxScope?: MuxToolScope;
/** Callback to record file state for external edit detection (plan files) */
recordFileState?: (filePath: string, state: FileState) => Promise;
+ /** Optional callback for file-mutation follow-up work (for example, post-edit diagnostics). */
+ onFilesMutated?: (params: { filePaths: string[] }) => Promise;
/** Callback to notify that provider/config was written (triggers hot-reload). */
onConfigChanged?: () => void;
/** Best-effort callback for recording tool-initiated model usage in session totals. */
@@ -168,6 +173,12 @@ export interface ToolConfiguration {
};
/** Desktop session manager for desktop automation tools */
desktopSessionManager?: DesktopSessionManager;
+ /** Shared workspace-scoped LSP manager for built-in query tooling */
+ lspManager?: LspManager;
+ /** Effective LSP launch policy context for this workspace/request. */
+ lspPolicyContext?: LspPolicyContext;
+ /** Whether the experiment-gated lsp_query tool should be exposed for this request */
+ lspQueryEnabled?: boolean;
}
/**
@@ -394,6 +405,11 @@ export async function getToolsForModel(
agent_skill_read_file: wrap(createAgentSkillReadFileTool(config)),
file_edit_replace_string: wrap(createFileEditReplaceStringTool(config)),
file_edit_insert: wrap(createFileEditInsertTool(config)),
+ ...(config.lspManager && config.lspPolicyContext && config.lspQueryEnabled
+ ? {
+ lsp_query: wrap(createLspQueryTool(config)),
+ }
+ : {}),
// DISABLED: file_edit_replace_lines - causes models (particularly GPT-5-Codex)
// to leave repository in broken state due to issues with concurrent file modifications
// and line number miscalculations. Use file_edit_replace_string instead.
@@ -541,6 +557,10 @@ export async function getToolsForModel(
getAvailableTools(modelString, {
enableAgentReport: config.enableAgentReport,
enableAnalyticsQuery: Boolean(config.analyticsService),
+ enableLspQuery:
+ config.lspManager != null &&
+ config.lspPolicyContext != null &&
+ config.lspQueryEnabled === true,
enableAdvisor: Boolean(config.advisorRuntime),
// Mux global tools are always created; tool policy (agent frontmatter)
// controls which agents can actually use them.
diff --git a/src/constants/lsp.ts b/src/constants/lsp.ts
new file mode 100644
index 0000000000..8d8e910359
--- /dev/null
+++ b/src/constants/lsp.ts
@@ -0,0 +1,10 @@
+export const LSP_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
+export const LSP_IDLE_CHECK_INTERVAL_MS = 60 * 1000;
+export const LSP_DIAGNOSTICS_POLL_INTERVAL_MS = 2_000;
+export const LSP_REQUEST_TIMEOUT_MS = 10 * 1000;
+export const LSP_START_TIMEOUT_MS = 5 * 1000;
+export const LSP_POST_MUTATION_DIAGNOSTICS_TIMEOUT_MS = 1_500;
+export const LSP_MAX_LOCATIONS = 25;
+export const LSP_MAX_SYMBOLS = 100;
+export const LSP_MAX_WORKSPACE_SYMBOL_QUERY_ROOTS = 32;
+export const LSP_PREVIEW_CONTEXT_LINES = 1;
diff --git a/src/node/config.test.ts b/src/node/config.test.ts
index 81a3f7a20b..aeb48138f0 100644
--- a/src/node/config.test.ts
+++ b/src/node/config.test.ts
@@ -143,6 +143,87 @@ describe("Config", () => {
});
});
+ describe("lspProvisioningMode", () => {
+ const envKey = "MUX_LSP_PROVISIONING_MODE";
+ let originalEnvValue: string | undefined;
+
+ beforeEach(() => {
+ originalEnvValue = process.env[envKey];
+ delete process.env[envKey];
+ });
+
+ afterEach(() => {
+ if (originalEnvValue === undefined) {
+ delete process.env[envKey];
+ } else {
+ process.env[envKey] = originalEnvValue;
+ }
+ });
+
+ it("persists auto provisioning mode and omits the default manual mode", async () => {
+ await config.editConfig((cfg) => {
+ cfg.lspProvisioningMode = "auto";
+ return cfg;
+ });
+
+ let loaded = config.loadConfigOrDefault();
+ expect(loaded.lspProvisioningMode).toBe("auto");
+
+ let raw = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as {
+ lspProvisioningMode?: unknown;
+ };
+ expect(raw.lspProvisioningMode).toBe("auto");
+
+ await config.editConfig((cfg) => {
+ delete cfg.lspProvisioningMode;
+ return cfg;
+ });
+
+ loaded = config.loadConfigOrDefault();
+ expect(loaded.lspProvisioningMode).toBeUndefined();
+
+ raw = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as {
+ lspProvisioningMode?: unknown;
+ };
+ expect(raw.lspProvisioningMode).toBeUndefined();
+ });
+
+ it("prefers the env override over persisted config", () => {
+ fs.writeFileSync(
+ path.join(tempDir, "config.json"),
+ JSON.stringify({ lspProvisioningMode: "manual" })
+ );
+
+ process.env[envKey] = "auto";
+
+ expect(config.loadConfigOrDefault().lspProvisioningMode).toBe("auto");
+ });
+
+ it("applies the env override even when config is missing", () => {
+ process.env[envKey] = "manual";
+
+ expect(config.loadConfigOrDefault().lspProvisioningMode).toBe("manual");
+ });
+ it("does not persist the env override during unrelated config edits", async () => {
+ process.env[envKey] = "auto";
+
+ await config.editConfig((cfg) => {
+ cfg.apiServerPort = 31337;
+ return cfg;
+ });
+
+ expect(config.loadConfigOrDefault().lspProvisioningMode).toBe("auto");
+
+ const raw = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as {
+ apiServerPort?: unknown;
+ lspProvisioningMode?: unknown;
+ };
+ expect(raw.apiServerPort).toBe(31337);
+ expect(raw.lspProvisioningMode).toBeUndefined();
+ });
+
+ });
+
describe("server GitHub owner auth setting", () => {
it("persists serverAuthGithubOwner", async () => {
await config.editConfig((cfg) => {
diff --git a/src/node/config.ts b/src/node/config.ts
index 9fed36c8a4..797ae38f66 100644
--- a/src/node/config.ts
+++ b/src/node/config.ts
@@ -17,8 +17,10 @@ import type {
ProjectConfig,
ProjectsConfig,
FeatureFlagOverride,
+ LspProvisioningMode,
UpdateChannel,
} from "@/common/types/project";
+import { DEFAULT_LSP_PROVISIONING_MODE } from "@/common/config/schemas/appConfigOnDisk";
import type {
AppConfigOnDisk,
BaseProviderConfig as ProviderConfig,
@@ -108,6 +110,18 @@ function parseUpdateChannel(value: unknown): UpdateChannel | undefined {
return undefined;
}
+function parseLspProvisioningMode(value: unknown): LspProvisioningMode | undefined {
+ if (value === "manual" || value === "auto") {
+ return value;
+ }
+
+ return undefined;
+}
+
+function getLspProvisioningModeEnvOverride(): LspProvisioningMode | undefined {
+ return parseLspProvisioningMode(process.env.MUX_LSP_PROVISIONING_MODE);
+}
+
function parseCoderWorkspaceArchiveBehavior(
value: unknown
): CoderWorkspaceArchiveBehavior | undefined {
@@ -480,7 +494,19 @@ export class Config {
return priority.length > 1 ? priority : undefined;
}
- loadConfigOrDefault(): ProjectsConfig {
+ private applyLspProvisioningModeEnvOverride(config: ProjectsConfig): ProjectsConfig {
+ const lspProvisioningMode = getLspProvisioningModeEnvOverride();
+ if (lspProvisioningMode == null) {
+ return config;
+ }
+
+ return {
+ ...config,
+ lspProvisioningMode,
+ };
+ }
+
+ private loadPersistedConfigOrDefault(): ProjectsConfig {
try {
if (fs.existsSync(this.configFile)) {
const data = fs.readFileSync(this.configFile, "utf-8");
@@ -719,6 +745,7 @@ export class Config {
const runtimeEnablement = normalizeRuntimeEnablementOverrides(parsed.runtimeEnablement);
const defaultRuntime = normalizeRuntimeEnablementId(parsed.defaultRuntime);
+ const lspProvisioningMode = parseLspProvisioningMode(parsed.lspProvisioningMode);
const agentAiDefaults =
parsed.agentAiDefaults !== undefined
@@ -773,6 +800,7 @@ export class Config {
updateChannel,
defaultRuntime,
runtimeEnablement,
+ lspProvisioningMode,
onePasswordAccountName: parseOptionalNonEmptyString(parsed.onePasswordAccountName),
};
}
@@ -793,6 +821,10 @@ export class Config {
};
}
+ loadConfigOrDefault(): ProjectsConfig {
+ return this.applyLspProvisioningModeEnvOverride(this.loadPersistedConfigOrDefault());
+ }
+
async saveConfig(config: ProjectsConfig): Promise {
try {
if (!fs.existsSync(this.rootDir)) {
@@ -1000,6 +1032,14 @@ export class Config {
data.defaultRuntime = defaultRuntime;
}
+ const lspProvisioningMode = parseLspProvisioningMode(config.lspProvisioningMode);
+ if (
+ lspProvisioningMode !== undefined &&
+ lspProvisioningMode !== DEFAULT_LSP_PROVISIONING_MODE
+ ) {
+ data.lspProvisioningMode = lspProvisioningMode;
+ }
+
const onePasswordAccountName = parseOptionalNonEmptyString(config.onePasswordAccountName);
if (onePasswordAccountName) {
data.onePasswordAccountName = onePasswordAccountName;
@@ -1016,7 +1056,7 @@ export class Config {
* @param fn Function that takes current config and returns modified config
*/
async editConfig(fn: (config: ProjectsConfig) => ProjectsConfig): Promise {
- const config = this.loadConfigOrDefault();
+ const config = this.loadPersistedConfigOrDefault();
const newConfig = fn(config);
await this.saveConfig(newConfig);
// Backend-initiated config edits (for example gateway auth changes) use this signal
@@ -1306,7 +1346,7 @@ export class Config {
* saved to config for subsequent loads.
*/
async getAllWorkspaceMetadata(): Promise {
- const config = this.loadConfigOrDefault();
+ const config = this.loadPersistedConfigOrDefault();
const workspaceMetadata: FrontendWorkspaceMetadata[] = [];
let configModified = false;
diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts
index eb05595838..2f1a6de79b 100644
--- a/src/node/orpc/context.ts
+++ b/src/node/orpc/context.ts
@@ -38,6 +38,7 @@ import type { CoderService } from "@/node/services/coderService";
import type { ServerAuthService } from "@/node/services/serverAuthService";
import type { SshPromptService } from "@/node/services/sshPromptService";
import type { AnalyticsService } from "@/node/services/analytics/analyticsService";
+import type { LspManager } from "@/node/services/lsp/lspManager";
import type { DesktopBridgeServer } from "@/node/services/desktop/DesktopBridgeServer";
import type { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager";
import type { DesktopTokenManager } from "@/node/services/desktop/DesktopTokenManager";
@@ -70,6 +71,7 @@ export interface ORPCContext {
telemetryService: TelemetryService;
experimentsService: ExperimentsService;
sessionUsageService: SessionUsageService;
+ lspManager: LspManager;
devToolsService: DevToolsService;
browserSessionDiscoveryService: AgentBrowserSessionDiscoveryService;
browserBridgeTokenManager: BrowserBridgeTokenManager;
diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts
index 364f336dc0..c4201e1ed0 100644
--- a/src/node/orpc/router.ts
+++ b/src/node/orpc/router.ts
@@ -1,5 +1,6 @@
import { os, ORPCError } from "@orpc/server";
import { DEFAULT_CODER_ARCHIVE_BEHAVIOR } from "@/common/config/coderArchiveBehavior";
+import { DEFAULT_LSP_PROVISIONING_MODE } from "@/common/config/schemas/appConfigOnDisk";
import * as schemas from "@/common/orpc/schemas";
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
import type { ORPCContext } from "./context";
@@ -16,6 +17,7 @@ import type {
UpdateStatus,
WorkspaceActivitySnapshot,
WorkspaceChatMessage,
+ WorkspaceLspDiagnosticsSnapshot,
WorkspaceStatsSnapshot,
FrontendWorkspaceMetadataSchemaType,
} from "@/common/orpc/types";
@@ -154,6 +156,47 @@ function isTrustedProjectPath(context: ORPCContext, projectPath?: string | null)
return isProjectTrusted(context.config, projectPath);
}
+async function disposeLspWorkspacesForProjects(
+ context: ORPCContext,
+ projectPaths?: readonly string[]
+): Promise {
+ const normalizedProjectPaths = projectPaths?.map((projectPath) =>
+ stripTrailingSlashes(projectPath)
+ );
+ const allWorkspaceMetadata = await context.config.getAllWorkspaceMetadata();
+ const workspaceIds = new Set(
+ allWorkspaceMetadata
+ .filter((metadata) => {
+ if (normalizedProjectPaths == null || normalizedProjectPaths.length === 0) {
+ return true;
+ }
+
+ const workspaceProjectPaths = [
+ metadata.projectPath,
+ ...(metadata.projects?.map((project) => project.projectPath) ?? []),
+ ].map((projectPath) => stripTrailingSlashes(projectPath));
+ return normalizedProjectPaths.some((projectPath) =>
+ workspaceProjectPaths.includes(projectPath)
+ );
+ })
+ .map((metadata) => metadata.id)
+ );
+
+ await Promise.all(
+ [...workspaceIds].map(async (workspaceId) => {
+ try {
+ await context.lspManager.disposeWorkspace(workspaceId);
+ } catch (error) {
+ log.debug("Failed to dispose LSP workspace after config/trust update", {
+ workspaceId,
+ projectPaths: normalizedProjectPaths,
+ error,
+ });
+ }
+ })
+ );
+}
+
function normalizeOptionalConfigString(value: string | null | undefined): string | undefined {
const trimmedValue = value?.trim();
if (!trimmedValue) {
@@ -622,6 +665,7 @@ export const router = (authToken?: string) => {
worktreeArchiveBehavior: config.worktreeArchiveBehavior ?? "keep",
runtimeEnablement: normalizeRuntimeEnablement(config.runtimeEnablement),
defaultRuntime: config.defaultRuntime ?? null,
+ lspProvisioningMode: config.lspProvisioningMode ?? DEFAULT_LSP_PROVISIONING_MODE,
agentAiDefaults: config.agentAiDefaults ?? {},
// Legacy fields (downgrade compatibility)
subagentAiDefaults: config.subagentAiDefaults ?? {},
@@ -1012,6 +1056,23 @@ export const router = (authToken?: string) => {
// Re-evaluate task queue in case more slots opened up
await context.taskService.maybeStartQueuedTasks();
}),
+ updateLspProvisioningMode: t
+ .input(schemas.config.updateLspProvisioningMode.input)
+ .output(schemas.config.updateLspProvisioningMode.output)
+ .handler(async ({ context, input }) => {
+ await context.config.editConfig((config) => {
+ const next = { ...config };
+ if (input.mode === DEFAULT_LSP_PROVISIONING_MODE) {
+ delete next.lspProvisioningMode;
+ } else {
+ next.lspProvisioningMode = input.mode;
+ }
+ return next;
+ });
+
+ // Provisioning mode changes alter launch-plan policy for every workspace.
+ await disposeLspWorkspacesForProjects(context);
+ }),
updateLlmDebugLogs: t
.input(schemas.config.updateLlmDebugLogs.input)
.output(schemas.config.updateLlmDebugLogs.output)
@@ -2496,8 +2557,8 @@ export const router = (authToken?: string) => {
.input(schemas.projects.setTrust.input)
.output(schemas.projects.setTrust.output)
.handler(async ({ context, input }) => {
+ const normalizedPath = stripTrailingSlashes(input.projectPath);
await context.config.editConfig((config) => {
- const normalizedPath = stripTrailingSlashes(input.projectPath);
let project = config.projects.get(normalizedPath);
if (!project) {
// Create a minimal project entry so trust can be set before
@@ -2508,6 +2569,9 @@ export const router = (authToken?: string) => {
project.trusted = input.trusted;
return config;
});
+
+ // Trust flips change which launch strategies are legal for this repo.
+ await disposeLspWorkspacesForProjects(context, [normalizedPath]);
}),
setDisplayName: t
.input(schemas.projects.setDisplayName.input)
@@ -3978,6 +4042,51 @@ export const router = (authToken?: string) => {
.handler(async ({ context, input }) => {
return context.sessionUsageService.getSessionUsageBatch(input.workspaceIds);
}),
+ lsp: {
+ listDiagnostics: t
+ .input(schemas.workspace.lsp.listDiagnostics.input)
+ .output(schemas.workspace.lsp.listDiagnostics.output)
+ .handler(({ context, input }) => {
+ return context.lspManager.getWorkspaceDiagnosticsSnapshot(input.workspaceId);
+ }),
+ subscribeDiagnostics: t
+ .input(schemas.workspace.lsp.subscribeDiagnostics.input)
+ .output(schemas.workspace.lsp.subscribeDiagnostics.output)
+ .handler(async function* ({ context, input, signal }) {
+ const workspaceId = input.workspaceId;
+
+ if (signal?.aborted) {
+ return;
+ }
+
+ context.lspManager.acquireLease(workspaceId);
+ const queue = createAsyncEventQueue();
+ const onAbort = () => {
+ queue.end();
+ };
+
+ if (signal) {
+ signal.addEventListener("abort", onAbort, { once: true });
+ }
+
+ const unsubscribe = context.lspManager.subscribeWorkspaceDiagnostics(
+ workspaceId,
+ (snapshot) => {
+ queue.push(snapshot);
+ }
+ );
+
+ try {
+ yield context.lspManager.getWorkspaceDiagnosticsSnapshot(workspaceId);
+ yield* queue.iterate();
+ } finally {
+ signal?.removeEventListener("abort", onAbort);
+ queue.end();
+ unsubscribe();
+ context.lspManager.releaseLease(workspaceId);
+ }
+ }),
+ },
stats: {
subscribe: t
.input(schemas.workspace.stats.subscribe.input)
diff --git a/src/node/runtime/multiProjectRuntime.ts b/src/node/runtime/multiProjectRuntime.ts
index 90f6a9a666..43ee79bf64 100644
--- a/src/node/runtime/multiProjectRuntime.ts
+++ b/src/node/runtime/multiProjectRuntime.ts
@@ -60,6 +60,10 @@ export class MultiProjectRuntime implements Runtime {
this.postCreateSetup = this.primaryRuntime.postCreateSetup?.bind(this.primaryRuntime);
}
+ getPrimaryRuntime(): Runtime {
+ return this.primaryRuntime;
+ }
+
async ensureReady(options?: EnsureReadyOptions): Promise {
for (const projectRuntime of this.projectRuntimes) {
const readyResult = await projectRuntime.runtime.ensureReady(options);
diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts
index db3c99e4bf..804e3b384d 100644
--- a/src/node/services/aiService.test.ts
+++ b/src/node/services/aiService.test.ts
@@ -2601,6 +2601,29 @@ describe("AIService.streamMessage multi-project trust gating", () => {
mock.restore();
});
+ it("enables lsp_query for a request-scoped experiment override", async () => {
+ using muxHome = new DisposableTempDir("ai-service-lsp-query-request-override");
+ const projectPath = path.join(muxHome.path, "project-a");
+ await fs.mkdir(projectPath, { recursive: true });
+
+ const workspaceId = "workspace-lsp-query-request-override";
+ const metadata = createTrustMetadata(workspaceId, [projectPath]);
+ const harness = createHarness(muxHome.path, metadata);
+
+ const result = await harness.service.streamMessage({
+ messages: [createMuxMessage("user-message", "user", "hello")],
+ workspaceId,
+ modelString: "openai:gpt-5.2",
+ thinkingLevel: "off",
+ experiments: { lspQuery: true },
+ });
+
+ expect(result.success).toBe(true);
+ const toolConfig = harness.getToolsForModelSpy.mock.calls[0]?.[1];
+ expect(toolConfig).toBeDefined();
+ expect(toolConfig?.lspQueryEnabled).toBe(true);
+ });
+
it("marks multi-project tool execution untrusted when any secondary project is untrusted", async () => {
using muxHome = new DisposableTempDir("ai-service-multi-project-trust-gating");
const projectAPath = path.join(muxHome.path, "project-a");
diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts
index 6fbdb4a8e1..8a3d77ba30 100644
--- a/src/node/services/aiService.ts
+++ b/src/node/services/aiService.ts
@@ -1,3 +1,4 @@
+import * as path from "node:path";
import * as fs from "fs/promises";
import { EventEmitter } from "events";
@@ -12,6 +13,7 @@ import type { WorkspaceMetadata } from "@/common/types/workspace";
import type { SendMessageOptions, ProvidersConfigMap } from "@/common/orpc/types";
import type { DebugLlmRequestSnapshot } from "@/common/types/debugLlmRequest";
+import { DEFAULT_LSP_PROVISIONING_MODE } from "@/common/config/schemas/appConfigOnDisk";
import { ADVISOR_DEFAULT_MAX_USES_PER_TURN } from "@/common/constants/advisor";
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
@@ -108,6 +110,12 @@ import {
import { applyToolPolicyAndExperiments, captureMcpToolTelemetry } from "./toolAssembly";
import { getErrorMessage } from "@/common/utils/errors";
import { isProjectTrusted } from "@/node/utils/projectTrust";
+import type { LspManager } from "@/node/services/lsp/lspManager";
+import type {
+ LspDiagnostic,
+ LspFileDiagnostics,
+ LspPolicyContext,
+} from "@/node/services/lsp/types";
const STREAM_STARTUP_DIAGNOSTIC_THRESHOLD_MS = 1_000;
@@ -255,12 +263,87 @@ function derivePromptCacheScope(metadata: WorkspaceMetadata): string {
return `${metadata.projectName}-${uniqueSuffix([metadata.projectPath])}`;
}
+const MAX_POST_EDIT_DIAGNOSTIC_LINES = 12;
+
+function formatPostMutationDiagnostics(
+ diagnostics: LspFileDiagnostics[],
+ workspacePath: string
+): string | undefined {
+ if (diagnostics.length === 0) {
+ return undefined;
+ }
+
+ const lines: string[] = [];
+ let omittedCount = 0;
+
+ for (const fileDiagnostics of diagnostics) {
+ for (const diagnostic of fileDiagnostics.diagnostics) {
+ if (lines.length >= MAX_POST_EDIT_DIAGNOSTIC_LINES) {
+ omittedCount += 1;
+ continue;
+ }
+
+ lines.push(formatPostMutationDiagnosticLine(fileDiagnostics, diagnostic, workspacePath));
+ }
+ }
+
+ if (lines.length === 0) {
+ return undefined;
+ }
+
+ return [
+ "Post-edit LSP diagnostics:",
+ ...lines.map((line) => `- ${line}`),
+ ...(omittedCount > 0 ? [`- ...and ${omittedCount} more diagnostics`] : []),
+ ].join("\n");
+}
+
+function formatPostMutationDiagnosticLine(
+ fileDiagnostics: LspFileDiagnostics,
+ diagnostic: LspDiagnostic,
+ workspacePath: string
+): string {
+ const relativePath = toWorkspaceRelativePath(fileDiagnostics.path, workspacePath);
+ const line = diagnostic.range.start.line + 1;
+ const column = diagnostic.range.start.character + 1;
+ const severity = formatDiagnosticSeverity(diagnostic.severity);
+ const source = diagnostic.source ? ` ${diagnostic.source}` : "";
+ const code = diagnostic.code != null ? ` ${String(diagnostic.code)}` : "";
+ const message = diagnostic.message.replace(/\s+/g, " ").trim();
+ return `${relativePath}:${line}:${column} ${severity}${source}${code}: ${message}`;
+}
+
+function formatDiagnosticSeverity(severity: LspDiagnostic["severity"]): string {
+ switch (severity) {
+ case 1:
+ return "error";
+ case 2:
+ return "warning";
+ case 3:
+ return "info";
+ case 4:
+ return "hint";
+ default:
+ return "diagnostic";
+ }
+}
+
+function toWorkspaceRelativePath(filePath: string, workspacePath: string): string {
+ const relativePath = path.relative(workspacePath, filePath);
+ if (relativePath.length === 0) {
+ return ".";
+ }
+
+ return relativePath.startsWith("..") ? filePath : relativePath;
+}
+
export class AIService extends EventEmitter {
private readonly streamManager: StreamManager;
private readonly historyService: HistoryService;
private readonly config: Config;
private readonly workspaceMcpOverridesService: WorkspaceMcpOverridesService;
private mcpServerManager?: MCPServerManager;
+ private lspManager?: LspManager;
private readonly policyService?: PolicyService;
private readonly telemetryService?: TelemetryService;
private readonly opResolver?: ExternalSecretResolver;
@@ -362,6 +445,10 @@ export class AIService extends EventEmitter {
this.streamManager.setMCPServerManager(manager);
}
+ setLspManager(manager: LspManager): void {
+ this.lspManager = manager;
+ }
+
setTaskService(taskService: TaskService): void {
this.taskService = taskService;
}
@@ -1118,6 +1205,11 @@ export class AIService extends EventEmitter {
const advisorToolEligible =
advisorExperimentEnabled && agentAdvisorEnabled && advisorModelString.length > 0;
+ const lspPolicyContext: LspPolicyContext = {
+ provisioningMode: cfg.lspProvisioningMode ?? DEFAULT_LSP_PROVISIONING_MODE,
+ trustedWorkspaceExecution: sharedExecutionTrusted,
+ };
+
// Fetch workspace MCP overrides (for filtering servers and tools)
// NOTE: Stored in /.mux/mcp.local.jsonc (not ~/.mux/config.json).
let mcpOverrides: WorkspaceMCPOverrides | undefined;
@@ -1170,6 +1262,10 @@ export class AIService extends EventEmitter {
const muxScope = resolveMuxToolScope(this.config, metadata, workspacePath);
const desktopSessionManager = this.desktopSessionManager;
+ const lspQueryEnabled =
+ experiments?.lspQuery ??
+ this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.LSP_QUERY) ??
+ false;
let desktopCapabilityPromise: ReturnType | undefined;
const loadDesktopCapability =
desktopSessionManager == null
@@ -1259,7 +1355,10 @@ export class AIService extends EventEmitter {
runtime,
workspacePath,
modelString,
- agentSystemPrompt
+ agentSystemPrompt,
+ {
+ enableLspQuery: lspQueryEnabled,
+ }
);
recordStartupPhaseTiming("readToolInstructionsMs", readToolInstructionsStartedAt);
@@ -1498,6 +1597,29 @@ export class AIService extends EventEmitter {
enableAgentReport: Boolean(metadata.parentWorkspaceId),
// External edit detection callback
recordFileState,
+ onFilesMutated: async ({ filePaths }) => {
+ if (!this.lspManager) {
+ return undefined;
+ }
+
+ try {
+ const diagnostics = await this.lspManager.collectPostMutationDiagnostics({
+ workspaceId,
+ runtime,
+ workspacePath,
+ filePaths,
+ policyContext: lspPolicyContext,
+ });
+ return formatPostMutationDiagnostics(diagnostics, workspacePath);
+ } catch (error) {
+ log.debug("Failed to collect post-mutation LSP diagnostics", {
+ workspaceId,
+ filePaths,
+ error,
+ });
+ return undefined;
+ }
+ },
reportModelUsage: (event) => {
void (async () => {
try {
@@ -1552,6 +1674,9 @@ export class AIService extends EventEmitter {
taskService: this.taskService,
analyticsService: this.analyticsService,
desktopSessionManager: this.desktopSessionManager,
+ lspManager: this.lspManager,
+ lspPolicyContext,
+ lspQueryEnabled,
// PTC experiments for inheritance to subagents
experiments,
// Dynamic context for tool descriptions (moved from system prompt for better model attention)
diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts
index e5b4a42e48..49366a4aab 100644
--- a/src/node/services/coreServices.ts
+++ b/src/node/services/coreServices.ts
@@ -13,6 +13,7 @@ import { BackgroundProcessManager } from "@/node/services/backgroundProcessManag
import { SessionUsageService } from "@/node/services/sessionUsageService";
import { MCPConfigService } from "@/node/services/mcpConfigService";
import { MCPServerManager, type MCPServerManagerOptions } from "@/node/services/mcpServerManager";
+import { LspManager } from "@/node/services/lsp/lspManager";
import { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService";
import { WorkspaceService } from "@/node/services/workspaceService";
import { TaskService } from "@/node/services/taskService";
@@ -49,6 +50,7 @@ export interface CoreServices {
aiService: AIService;
mcpConfigService: MCPConfigService;
mcpServerManager: MCPServerManager;
+ lspManager: LspManager;
extensionMetadata: ExtensionMetadataService;
workspaceService: WorkspaceService;
taskService: TaskService;
@@ -88,6 +90,8 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices {
opts.policyService
);
aiService.setMCPServerManager(mcpServerManager);
+ const lspManager = new LspManager();
+ aiService.setLspManager(lspManager);
const extensionMetadata = new ExtensionMetadataService(extensionMetadataPath);
@@ -106,6 +110,7 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices {
opts.opResolver
);
workspaceService.setMCPServerManager(mcpServerManager);
+ workspaceService.setLspManager(lspManager);
const taskService = new TaskService(
config,
@@ -127,6 +132,7 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices {
aiService,
mcpConfigService,
mcpServerManager,
+ lspManager,
extensionMetadata,
workspaceService,
taskService,
diff --git a/src/node/services/lsp/lspClient.test.ts b/src/node/services/lsp/lspClient.test.ts
new file mode 100644
index 0000000000..870ae70c97
--- /dev/null
+++ b/src/node/services/lsp/lspClient.test.ts
@@ -0,0 +1,400 @@
+import * as fs from "node:fs/promises";
+import * as os from "node:os";
+import * as path from "node:path";
+import { describe, expect, it, mock } from "bun:test";
+import { LocalRuntime } from "@/node/runtime/LocalRuntime";
+import { LspClient } from "./lspClient";
+import type {
+ CreateLspClientOptions,
+ LspPublishDiagnosticsParams,
+ LspServerDescriptor,
+} from "./types";
+
+function createDescriptor(): LspServerDescriptor {
+ return {
+ id: "typescript",
+ extensions: [".ts"],
+ launch: {
+ type: "manual",
+ command: "fake-lsp",
+ args: ["--stdio"],
+ },
+ rootMarkers: ["package.json", ".git"],
+ languageIdForPath: () => "typescript",
+ };
+}
+
+function createTransport() {
+ return {
+ onmessage: undefined as
+ | ((message: {
+ jsonrpc: "2.0";
+ id?: number | string;
+ method?: string;
+ params?: unknown;
+ }) => void)
+ | undefined,
+ onclose: undefined as (() => void) | undefined,
+ onerror: undefined as ((error: Error) => void) | undefined,
+ start: mock(() => undefined),
+ send: mock(() => Promise.resolve()),
+ close: mock(() => Promise.resolve()),
+ isClosed: () => false,
+ getStderrTail: () => "",
+ };
+}
+
+type LspClientConstructor = new (
+ clientOptions: CreateLspClientOptions,
+ transport: ReturnType
+) => LspClient;
+
+function createClient(options?: Partial) {
+ const transport = createTransport();
+ const ClientConstructor = LspClient as unknown as LspClientConstructor;
+ const clientOptions: CreateLspClientOptions = {
+ descriptor: createDescriptor(),
+ launchPlan: {
+ command: "fake-lsp",
+ args: ["--stdio"],
+ cwd: "/tmp/workspace",
+ },
+ runtime: new LocalRuntime("/tmp/workspace"),
+ rootPath: "/tmp/workspace",
+ rootUri: "file:///tmp/workspace",
+ ...options,
+ };
+ const client = new ClientConstructor(clientOptions, transport);
+
+ return {
+ client,
+ transport,
+ };
+}
+
+describe("LspClient", () => {
+ it("launches from the resolved plan and forwards initialization options", async () => {
+ const requests: Array<{ id?: number | string; method?: string; params?: unknown }> = [];
+ const exec = mock(
+ (_command: string, _options: { cwd: string; env?: Record }) => {
+ let stdoutController!: ReadableStreamDefaultController;
+ const stdout = new ReadableStream({
+ start(controller) {
+ stdoutController = controller;
+ },
+ });
+ const stderr = new ReadableStream({
+ start(controller) {
+ controller.close();
+ },
+ });
+ let resolveExitCode!: (value: number) => void;
+ const exitCode = new Promise((resolve) => {
+ resolveExitCode = resolve;
+ });
+ const decoder = new TextDecoder();
+ const encoder = new TextEncoder();
+ const stdin = new WritableStream({
+ write(chunk) {
+ const payload = decoder.decode(chunk);
+ const headerEnd = payload.indexOf("\r\n\r\n");
+ if (headerEnd === -1) {
+ throw new Error(`Missing LSP frame header: ${payload}`);
+ }
+
+ const message = JSON.parse(payload.slice(headerEnd + 4)) as {
+ id?: number | string;
+ method?: string;
+ params?: unknown;
+ };
+ requests.push(message);
+
+ if (message.id == null) {
+ return;
+ }
+
+ const responseBody = JSON.stringify({
+ jsonrpc: "2.0",
+ id: message.id,
+ result: message.method === "initialize" ? { capabilities: {} } : null,
+ });
+ const responseFrame = `Content-Length: ${responseBody.length}\r\n\r\n${responseBody}`;
+ stdoutController.enqueue(encoder.encode(responseFrame));
+ },
+ close() {
+ resolveExitCode(0);
+ },
+ });
+
+ return Promise.resolve({
+ stdout,
+ stderr,
+ stdin,
+ exitCode,
+ duration: Promise.resolve(0),
+ });
+ }
+ );
+ const runtime = new LocalRuntime("/tmp/workspace");
+ runtime.exec = exec;
+
+ const client = await LspClient.create({
+ descriptor: createDescriptor(),
+ launchPlan: {
+ command: "/tmp/.mux/bin/typescript-language-server",
+ args: ["--stdio"],
+ cwd: "/tmp/workspace/.lsp",
+ env: { LSP_TRACE: "verbose" },
+ initializationOptions: {
+ preferences: {
+ includeCompletionsForModuleExports: true,
+ },
+ },
+ },
+ runtime,
+ rootPath: "/tmp/workspace",
+ rootUri: "file:///tmp/workspace",
+ });
+
+ expect(exec).toHaveBeenCalledTimes(1);
+ expect(exec.mock.calls[0]).toEqual([
+ "'/tmp/.mux/bin/typescript-language-server' '--stdio'",
+ {
+ cwd: "/tmp/workspace/.lsp",
+ env: { LSP_TRACE: "verbose" },
+ },
+ ]);
+ expect(requests[0]?.method).toBe("initialize");
+ expect(requests[0]?.params).toMatchObject({
+ rootUri: "file:///tmp/workspace",
+ rootPath: "/tmp/workspace",
+ initializationOptions: {
+ preferences: {
+ includeCompletionsForModuleExports: true,
+ },
+ },
+ });
+
+ await client.close();
+ expect(requests.at(-2)?.method).toBe("shutdown");
+ expect(requests.at(-1)?.method).toBe("exit");
+ });
+});
+
+describe("LspClient tracked files", () => {
+ it("returns the files tracked by ensureFile without exposing internal state", async () => {
+ const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "mux-lsp-client-"));
+ try {
+ const filePath = path.join(workspacePath, "src", "example.ts");
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.writeFile(filePath, "export const value = 1;\n");
+
+ const { client } = createClient({
+ runtime: new LocalRuntime(workspacePath),
+ rootPath: workspacePath,
+ rootUri: `file://${workspacePath}`,
+ });
+ (client as unknown as { initialized: boolean }).initialized = true;
+
+ const trackedFile = {
+ runtimePath: filePath,
+ readablePath: filePath,
+ uri: `file://${filePath}`,
+ languageId: "typescript",
+ };
+ await client.ensureFile(trackedFile);
+
+ const trackedFiles = client.getTrackedFiles();
+ expect(trackedFiles).toEqual([trackedFile]);
+
+ const firstTrackedFile = trackedFiles[0];
+ expect(firstTrackedFile).toBeDefined();
+ if (!firstTrackedFile) {
+ throw new Error("Expected ensureFile to track the opened file");
+ }
+ firstTrackedFile.readablePath = "/tmp/mutated.ts";
+ expect(client.getTrackedFiles()).toEqual([trackedFile]);
+ } finally {
+ await fs.rm(workspacePath, { recursive: true, force: true });
+ }
+ });
+
+ it("sends didChange with the incremented version when ensureFile sees new content", async () => {
+ const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "mux-lsp-client-change-"));
+ try {
+ const filePath = path.join(workspacePath, "src", "example.ts");
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.writeFile(filePath, "export const value = 1;\n");
+
+ const { client, transport } = createClient({
+ runtime: new LocalRuntime(workspacePath),
+ rootPath: workspacePath,
+ rootUri: `file://${workspacePath}`,
+ });
+ (client as unknown as { initialized: boolean }).initialized = true;
+
+ const trackedFile = {
+ runtimePath: filePath,
+ readablePath: filePath,
+ uri: `file://${filePath}`,
+ languageId: "typescript",
+ };
+
+ await client.ensureFile(trackedFile);
+ await fs.writeFile(filePath, "export const value = 2;\n");
+ const nextVersion = await client.ensureFile(trackedFile);
+
+ expect(nextVersion).toBe(2);
+ expect(transport.send).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({ method: "textDocument/didOpen" })
+ );
+ expect(transport.send).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ method: "textDocument/didChange",
+ params: {
+ textDocument: {
+ uri: trackedFile.uri,
+ version: 2,
+ },
+ contentChanges: [{ text: "export const value = 2;\n" }],
+ },
+ })
+ );
+ } finally {
+ await fs.rm(workspacePath, { recursive: true, force: true });
+ }
+ });
+});
+
+describe("LspClient tracked file cleanup", () => {
+ it("removes closed tracked files and notifies the server", async () => {
+ const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "mux-lsp-client-close-"));
+ try {
+ const filePath = path.join(workspacePath, "src", "example.ts");
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.writeFile(filePath, "export const value = 1;\n");
+
+ const { client, transport } = createClient({
+ runtime: new LocalRuntime(workspacePath),
+ rootPath: workspacePath,
+ rootUri: `file://${workspacePath}`,
+ });
+ (client as unknown as { initialized: boolean }).initialized = true;
+
+ const trackedFile = {
+ runtimePath: filePath,
+ readablePath: filePath,
+ uri: `file://${filePath}`,
+ languageId: "typescript",
+ };
+ await client.ensureFile(trackedFile);
+ await client.closeTrackedFile(trackedFile.uri);
+ await client.closeTrackedFile(trackedFile.uri);
+
+ expect(client.getTrackedFiles()).toEqual([]);
+ expect(transport.send).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({ method: "textDocument/didOpen" })
+ );
+ expect(transport.send).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({ method: "textDocument/didClose" })
+ );
+ } finally {
+ await fs.rm(workspacePath, { recursive: true, force: true });
+ }
+ });
+});
+
+describe("LspClient publishDiagnostics handling", () => {
+ it("forwards valid publishDiagnostics notifications", () => {
+ const onPublishDiagnostics = mock((_params: LspPublishDiagnosticsParams) => undefined);
+ const { transport } = createClient({ onPublishDiagnostics });
+
+ transport.onmessage?.({
+ jsonrpc: "2.0",
+ method: "textDocument/publishDiagnostics",
+ params: {
+ uri: "file:///tmp/workspace/src/example.ts",
+ version: 2,
+ diagnostics: [
+ {
+ range: {
+ start: { line: 0, character: 4 },
+ end: { line: 0, character: 9 },
+ },
+ severity: 1,
+ code: "TS2322",
+ source: "tsserver",
+ message: "Type 'string' is not assignable to type 'number'.",
+ },
+ ],
+ },
+ });
+
+ expect(onPublishDiagnostics).toHaveBeenCalledTimes(1);
+ expect(onPublishDiagnostics.mock.calls[0]?.[0]).toEqual({
+ uri: "file:///tmp/workspace/src/example.ts",
+ version: 2,
+ diagnostics: [
+ {
+ range: {
+ start: { line: 0, character: 4 },
+ end: { line: 0, character: 9 },
+ },
+ severity: 1,
+ code: "TS2322",
+ source: "tsserver",
+ message: "Type 'string' is not assignable to type 'number'.",
+ },
+ ],
+ rawDiagnosticCount: 1,
+ });
+ });
+
+ it("tags explicit clears so the manager can distinguish them from malformed publishes", () => {
+ const onPublishDiagnostics = mock((_params: LspPublishDiagnosticsParams) => undefined);
+ const { transport } = createClient({ onPublishDiagnostics });
+
+ transport.onmessage?.({
+ jsonrpc: "2.0",
+ method: "textDocument/publishDiagnostics",
+ params: {
+ uri: "file:///tmp/workspace/src/example.ts",
+ diagnostics: [],
+ },
+ });
+
+ expect(onPublishDiagnostics).toHaveBeenCalledTimes(1);
+ expect(onPublishDiagnostics.mock.calls[0]?.[0]).toEqual({
+ uri: "file:///tmp/workspace/src/example.ts",
+ version: undefined,
+ diagnostics: [],
+ rawDiagnosticCount: 0,
+ });
+ });
+
+ it("preserves malformed inner diagnostics so the manager can ignore them", () => {
+ const onPublishDiagnostics = mock((_params: LspPublishDiagnosticsParams) => undefined);
+ const { transport } = createClient({ onPublishDiagnostics });
+
+ transport.onmessage?.({
+ jsonrpc: "2.0",
+ method: "textDocument/publishDiagnostics",
+ params: {
+ uri: "file:///tmp/workspace/src/example.ts",
+ diagnostics: [{ message: "missing range" }],
+ },
+ });
+
+ expect(onPublishDiagnostics).toHaveBeenCalledTimes(1);
+ expect(onPublishDiagnostics.mock.calls[0]?.[0]).toEqual({
+ uri: "file:///tmp/workspace/src/example.ts",
+ version: undefined,
+ diagnostics: [],
+ rawDiagnosticCount: 1,
+ });
+ });
+});
diff --git a/src/node/services/lsp/lspClient.ts b/src/node/services/lsp/lspClient.ts
new file mode 100644
index 0000000000..b0c2a7511c
--- /dev/null
+++ b/src/node/services/lsp/lspClient.ts
@@ -0,0 +1,573 @@
+import { readFileString } from "@/node/utils/runtime/helpers";
+import { shellQuote } from "@/common/utils/shell";
+import { LSP_REQUEST_TIMEOUT_MS, LSP_START_TIMEOUT_MS } from "@/constants/lsp";
+import { log } from "@/node/services/log";
+import { LspStdioTransport, type LspJsonRpcMessage } from "./lspStdioTransport";
+import type {
+ CreateLspClientOptions,
+ LspClientFileHandle,
+ LspClientInstance,
+ LspClientQueryRequest,
+ LspClientQueryResult,
+ LspDiagnostic,
+ LspDocumentSymbol,
+ LspHover,
+ LspLocation,
+ LspLocationLink,
+ LspMarkedString,
+ LspMarkupContent,
+ LspPublishDiagnosticsParams,
+ LspSymbolInformation,
+} from "./types";
+
+interface PendingRequest {
+ resolve: (value: unknown) => void;
+ reject: (error: Error) => void;
+ timeoutId: ReturnType;
+}
+
+interface OpenDocumentState {
+ file: LspClientFileHandle;
+ version: number;
+ text: string;
+}
+
+interface InitializeResult {
+ capabilities?: Record;
+}
+
+export class LspClient implements LspClientInstance {
+ private readonly transport: LspStdioTransport;
+ private readonly pendingRequests = new Map();
+ private readonly openDocuments = new Map();
+ private nextRequestId = 1;
+ private initialized = false;
+ isClosed = false;
+
+ private constructor(
+ private readonly options: CreateLspClientOptions,
+ transport: LspStdioTransport
+ ) {
+ this.transport = transport;
+ this.transport.onmessage = (message) => this.handleMessage(message);
+ this.transport.onclose = () => this.handleClose();
+ this.transport.onerror = (error) => this.handleTransportError(error);
+ }
+
+ static async create(options: CreateLspClientOptions): Promise {
+ const command = [
+ shellQuote(options.launchPlan.command),
+ ...options.launchPlan.args.map((arg) => shellQuote(arg)),
+ ].join(" ");
+ const execStream = await options.runtime.exec(command, {
+ cwd: options.launchPlan.cwd ?? options.rootPath,
+ ...(options.launchPlan.env ? { env: { ...options.launchPlan.env } } : {}),
+ // LSP servers are long-lived by design; timeout would kill healthy clients mid-session.
+ });
+
+ const transport = new LspStdioTransport(execStream);
+ const client = new LspClient(options, transport);
+ await client.start();
+ return client;
+ }
+
+ async ensureFile(file: LspClientFileHandle): Promise {
+ this.ensureStarted();
+
+ const text = await readFileString(this.options.runtime, file.readablePath);
+ const existingState = this.openDocuments.get(file.uri);
+
+ if (!existingState) {
+ this.openDocuments.set(file.uri, {
+ file: { ...file },
+ version: 1,
+ text,
+ });
+ await this.notify("textDocument/didOpen", {
+ textDocument: {
+ uri: file.uri,
+ languageId: file.languageId,
+ version: 1,
+ text,
+ },
+ });
+ return 1;
+ }
+
+ if (existingState.text === text) {
+ existingState.file = { ...file };
+ return existingState.version;
+ }
+
+ const nextVersion = existingState.version + 1;
+ this.openDocuments.set(file.uri, {
+ file: { ...file },
+ version: nextVersion,
+ text,
+ });
+ await this.notify("textDocument/didChange", {
+ textDocument: {
+ uri: file.uri,
+ version: nextVersion,
+ },
+ contentChanges: [{ text }],
+ });
+ return nextVersion;
+ }
+
+ async closeTrackedFile(uri: string): Promise {
+ const existingState = this.openDocuments.get(uri);
+ if (!existingState) {
+ return;
+ }
+
+ this.openDocuments.delete(uri);
+ if (!this.initialized || this.isClosed || this.transport.isClosed()) {
+ return;
+ }
+
+ await this.notify("textDocument/didClose", {
+ textDocument: {
+ uri: existingState.file.uri,
+ },
+ });
+ }
+
+ getTrackedFiles(): readonly LspClientFileHandle[] {
+ return [...this.openDocuments.values()].map(({ file }) => ({ ...file }));
+ }
+
+ async query(request: LspClientQueryRequest): Promise {
+ this.ensureStarted();
+
+ switch (request.operation) {
+ case "hover": {
+ const result = (await this.request(
+ "textDocument/hover",
+ this.createTextDocumentPositionParams(request),
+ LSP_REQUEST_TIMEOUT_MS
+ )) as LspHover | null;
+ return {
+ operation: request.operation,
+ hover: result ? normalizeHoverContents(result.contents) : "",
+ };
+ }
+ case "definition": {
+ const result = (await this.request(
+ "textDocument/definition",
+ this.createTextDocumentPositionParams(request),
+ LSP_REQUEST_TIMEOUT_MS
+ )) as LspLocation | LspLocationLink | Array | null;
+ return {
+ operation: request.operation,
+ locations: normalizeLocations(result),
+ };
+ }
+ case "references": {
+ const result = (await this.request(
+ "textDocument/references",
+ {
+ ...this.createTextDocumentPositionParams(request),
+ context: {
+ includeDeclaration: request.includeDeclaration === true,
+ },
+ },
+ LSP_REQUEST_TIMEOUT_MS
+ )) as LspLocation[] | null;
+ return {
+ operation: request.operation,
+ locations: normalizeLocations(result),
+ };
+ }
+ case "implementation": {
+ const result = (await this.request(
+ "textDocument/implementation",
+ this.createTextDocumentPositionParams(request),
+ LSP_REQUEST_TIMEOUT_MS
+ )) as LspLocation | LspLocationLink | Array | null;
+ return {
+ operation: request.operation,
+ locations: normalizeLocations(result),
+ };
+ }
+ case "document_symbols": {
+ const file = this.getRequiredFile(request);
+ const result = (await this.request(
+ "textDocument/documentSymbol",
+ {
+ textDocument: {
+ uri: file.uri,
+ },
+ },
+ LSP_REQUEST_TIMEOUT_MS
+ )) as Array | null;
+ return {
+ operation: request.operation,
+ symbols: result ?? [],
+ };
+ }
+ case "workspace_symbols": {
+ const result = (await this.request(
+ "workspace/symbol",
+ {
+ query: request.query ?? "",
+ },
+ LSP_REQUEST_TIMEOUT_MS
+ )) as Array | null;
+ return {
+ operation: request.operation,
+ symbols: result ?? [],
+ };
+ }
+ }
+ }
+
+ async close(): Promise {
+ if (this.isClosed) {
+ return;
+ }
+
+ try {
+ if (this.initialized) {
+ await this.request("shutdown", undefined, 3000);
+ await this.notify("exit");
+ }
+ } catch (error) {
+ log.debug("Failed to shut down LSP client cleanly", {
+ serverId: this.options.descriptor.id,
+ error,
+ });
+ } finally {
+ this.isClosed = true;
+ await this.transport.close();
+ }
+ }
+
+ private async start(): Promise {
+ this.transport.start();
+ const response = (await this.request(
+ "initialize",
+ {
+ processId: process.pid,
+ rootUri: this.options.rootUri,
+ rootPath: this.options.rootPath,
+ workspaceFolders: [
+ {
+ uri: this.options.rootUri,
+ name: this.options.descriptor.id,
+ },
+ ],
+ capabilities: {
+ workspace: {
+ workspaceFolders: true,
+ },
+ textDocument: {
+ hover: {
+ contentFormat: ["markdown", "plaintext"],
+ },
+ definition: {
+ linkSupport: true,
+ },
+ implementation: {
+ linkSupport: true,
+ },
+ references: {},
+ documentSymbol: {
+ hierarchicalDocumentSymbolSupport: true,
+ },
+ },
+ },
+ ...(this.options.launchPlan.initializationOptions !== undefined
+ ? { initializationOptions: this.options.launchPlan.initializationOptions }
+ : {}),
+ },
+ LSP_START_TIMEOUT_MS
+ )) as InitializeResult;
+
+ this.initialized = true;
+ await this.notify("initialized", {});
+
+ log.debug("Started LSP client", {
+ serverId: this.options.descriptor.id,
+ rootUri: this.options.rootUri,
+ capabilities: response.capabilities ? Object.keys(response.capabilities) : [],
+ });
+ }
+
+ private ensureStarted(): void {
+ if (!this.initialized) {
+ throw new Error(`LSP client for ${this.options.descriptor.id} is not initialized`);
+ }
+ if (this.isClosed || this.transport.isClosed()) {
+ throw new Error(
+ `LSP client for ${this.options.descriptor.id} is closed${this.buildStderrSuffix()}`
+ );
+ }
+ }
+
+ private createTextDocumentPositionParams(request: LspClientQueryRequest) {
+ if (request.line == null || request.character == null) {
+ throw new Error(`${request.operation} requires a line and column`);
+ }
+
+ const file = this.getRequiredFile(request);
+ return {
+ textDocument: {
+ uri: file.uri,
+ },
+ position: {
+ line: request.line,
+ character: request.character,
+ },
+ };
+ }
+
+ private getRequiredFile(request: LspClientQueryRequest): LspClientFileHandle {
+ if (!request.file) {
+ throw new Error(`${request.operation} requires a file path`);
+ }
+
+ return request.file;
+ }
+
+ private async request(method: string, params: unknown, timeoutMs: number): Promise {
+ if (this.isClosed || this.transport.isClosed()) {
+ throw new Error(
+ `Cannot send ${method} to closed LSP client (${this.options.descriptor.id})${this.buildStderrSuffix()}`
+ );
+ }
+
+ const requestId = this.nextRequestId++;
+
+ return await new Promise((resolve, reject) => {
+ const timeoutId = setTimeout(() => {
+ this.pendingRequests.delete(requestId);
+ reject(
+ new Error(
+ `LSP request timed out: ${this.options.descriptor.id} ${method}${this.buildStderrSuffix()}`
+ )
+ );
+ }, timeoutMs);
+
+ this.pendingRequests.set(requestId, {
+ resolve,
+ reject,
+ timeoutId,
+ });
+
+ void this.transport
+ .send({
+ jsonrpc: "2.0",
+ id: requestId,
+ method,
+ ...(params !== undefined ? { params } : {}),
+ })
+ .catch((error) => {
+ const pending = this.pendingRequests.get(requestId);
+ if (!pending) {
+ return;
+ }
+
+ clearTimeout(pending.timeoutId);
+ this.pendingRequests.delete(requestId);
+ reject(error instanceof Error ? error : new Error(String(error)));
+ });
+ });
+ }
+
+ private async notify(method: string, params?: unknown): Promise {
+ if (this.isClosed || this.transport.isClosed()) {
+ return;
+ }
+
+ await this.transport.send({
+ jsonrpc: "2.0",
+ method,
+ ...(params !== undefined ? { params } : {}),
+ });
+ }
+
+ private handleMessage(message: LspJsonRpcMessage): void {
+ if (typeof message.id === "number") {
+ const pending = this.pendingRequests.get(message.id);
+ if (!pending) {
+ return;
+ }
+
+ clearTimeout(pending.timeoutId);
+ this.pendingRequests.delete(message.id);
+
+ if (message.error) {
+ pending.reject(
+ new Error(
+ `LSP ${this.options.descriptor.id} request failed: ${message.error.message}${this.buildStderrSuffix()}`
+ )
+ );
+ return;
+ }
+
+ pending.resolve(message.result);
+ return;
+ }
+
+ if (message.method === "textDocument/publishDiagnostics") {
+ const params = parsePublishDiagnosticsParams(message.params);
+ if (params) {
+ this.options.onPublishDiagnostics?.(params);
+ }
+ }
+ }
+
+ private handleClose(): void {
+ this.isClosed = true;
+ const error = new Error(
+ `LSP client for ${this.options.descriptor.id} closed unexpectedly${this.buildStderrSuffix()}`
+ );
+ this.rejectAllPending(error);
+ }
+
+ private handleTransportError(error: Error): void {
+ this.isClosed = true;
+ this.rejectAllPending(
+ new Error(
+ `LSP transport error for ${this.options.descriptor.id}: ${error.message}${this.buildStderrSuffix()}`
+ )
+ );
+ }
+
+ private rejectAllPending(error: Error): void {
+ for (const [requestId, pending] of this.pendingRequests) {
+ clearTimeout(pending.timeoutId);
+ pending.reject(error);
+ this.pendingRequests.delete(requestId);
+ }
+ }
+
+ private buildStderrSuffix(): string {
+ const stderrTail = this.transport.getStderrTail();
+ return stderrTail.length > 0 ? `; stderr: ${stderrTail}` : "";
+ }
+}
+
+function parsePublishDiagnosticsParams(params: unknown): LspPublishDiagnosticsParams | null {
+ if (typeof params !== "object" || params == null || Array.isArray(params)) {
+ return null;
+ }
+
+ const record = params as Record;
+ if (typeof record.uri !== "string" || !Array.isArray(record.diagnostics)) {
+ return null;
+ }
+
+ const diagnostics = record.diagnostics
+ .map((diagnostic) => parseDiagnostic(diagnostic))
+ .filter((diagnostic): diagnostic is LspDiagnostic => diagnostic !== null);
+
+ return {
+ uri: record.uri,
+ version: typeof record.version === "number" ? record.version : undefined,
+ diagnostics,
+ rawDiagnosticCount: record.diagnostics.length,
+ };
+}
+
+function parseDiagnostic(value: unknown): LspDiagnostic | null {
+ if (typeof value !== "object" || value == null || Array.isArray(value)) {
+ return null;
+ }
+
+ const record = value as Record;
+ const range = parseRange(record.range);
+ if (!range || typeof record.message !== "string") {
+ return null;
+ }
+
+ const severity =
+ typeof record.severity === "number" && record.severity >= 1 && record.severity <= 4
+ ? (record.severity as 1 | 2 | 3 | 4)
+ : undefined;
+ const code =
+ typeof record.code === "string" || typeof record.code === "number" ? record.code : undefined;
+
+ return {
+ range,
+ message: record.message,
+ severity,
+ code,
+ source: typeof record.source === "string" ? record.source : undefined,
+ };
+}
+
+function parseRange(value: unknown): LspLocation["range"] | null {
+ if (typeof value !== "object" || value == null || Array.isArray(value)) {
+ return null;
+ }
+
+ const record = value as Record;
+ const start = parsePosition(record.start);
+ const end = parsePosition(record.end);
+ if (!start || !end) {
+ return null;
+ }
+
+ return { start, end };
+}
+
+function parsePosition(value: unknown): LspLocation["range"]["start"] | null {
+ if (typeof value !== "object" || value == null || Array.isArray(value)) {
+ return null;
+ }
+
+ const record = value as Record;
+ if (typeof record.line !== "number" || typeof record.character !== "number") {
+ return null;
+ }
+
+ return {
+ line: record.line,
+ character: record.character,
+ };
+}
+
+function normalizeLocations(
+ value: LspLocation | LspLocationLink | Array | null | undefined
+): LspLocation[] {
+ if (!value) {
+ return [];
+ }
+
+ const values = Array.isArray(value) ? value : [value];
+ return values.map((entry) => {
+ if ("targetUri" in entry) {
+ return {
+ uri: entry.targetUri,
+ range: entry.targetRange,
+ };
+ }
+
+ return entry;
+ });
+}
+
+function normalizeHoverContents(contents: LspHover["contents"]): string {
+ if (typeof contents === "string") {
+ return contents;
+ }
+
+ if (Array.isArray(contents)) {
+ return contents
+ .map((entry) => normalizeHoverContents(entry))
+ .filter((entry) => entry.length > 0)
+ .join("\n\n");
+ }
+
+ if (isMarkupContent(contents)) {
+ return contents.value;
+ }
+
+ return contents.value;
+}
+
+function isMarkupContent(value: LspMarkupContent | LspMarkedString): value is LspMarkupContent {
+ return "kind" in value;
+}
diff --git a/src/node/services/lsp/lspLaunchProvisioning.ts b/src/node/services/lsp/lspLaunchProvisioning.ts
new file mode 100644
index 0000000000..caac8fa0a1
--- /dev/null
+++ b/src/node/services/lsp/lspLaunchProvisioning.ts
@@ -0,0 +1,528 @@
+import * as path from "node:path";
+import { shellQuote } from "@/common/utils/shell";
+import type { ExecOptions, Runtime } from "@/node/runtime/Runtime";
+import { isPathInsideDir } from "@/node/utils/pathUtils";
+import { readFileString } from "@/node/utils/runtime/helpers";
+import type {
+ LspGoManagedInstallStrategy,
+ LspNodePackageExecStrategy,
+ LspNodePackageManager,
+ LspPolicyContext,
+} from "./types";
+
+const LSP_PROBE_TIMEOUT_SECONDS = 5;
+const DEFAULT_NODE_PACKAGE_MANAGERS: readonly LspNodePackageManager[] = ["bunx", "pnpm", "npm"];
+
+export async function probeCommandOnPath(
+ runtime: Runtime,
+ command: string,
+ cwd: string,
+ env?: Readonly>
+): Promise {
+ const result = await execProbe(runtime, `command -v ${shellQuote(command)}`, {
+ cwd,
+ ...(env ? { env: { ...env } } : {}),
+ timeout: LSP_PROBE_TIMEOUT_SECONDS,
+ });
+ if (result.exitCode !== 0) {
+ return null;
+ }
+
+ const resolvedCommand = result.stdout
+ .split(/\r?\n/u)
+ .map((line) => line.trim())
+ .find((line) => line.length > 0);
+ return resolvedCommand ?? null;
+}
+
+export async function resolveExecutablePathCandidate(
+ runtime: Runtime,
+ candidatePath: string,
+ cwd: string,
+ env?: Readonly>
+): Promise {
+ const normalizedCandidatePath = runtime.normalizePath(candidatePath, cwd);
+
+ try {
+ const resolvedCandidatePath = await runtime.resolvePath(normalizedCandidatePath);
+ const stat = await runtime.stat(resolvedCandidatePath);
+ if (stat.isDirectory) {
+ return null;
+ }
+
+ return (await isRunnablePath(runtime, resolvedCandidatePath, cwd, env))
+ ? resolvedCandidatePath
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+export async function probeWorkspaceLocalExecutable(
+ runtime: Runtime,
+ workspacePath: string,
+ relativeCandidates: readonly string[]
+): Promise {
+ for (const relativeCandidate of relativeCandidates) {
+ const resolvedCandidate = await resolveExecutablePathCandidate(
+ runtime,
+ relativeCandidate,
+ workspacePath
+ );
+ if (resolvedCandidate) {
+ return resolvedCandidate;
+ }
+ }
+
+ return null;
+}
+
+export async function probeWorkspaceLocalExecutableForWorkspace(
+ runtime: Runtime,
+ projectPath: string,
+ workspaceName: string,
+ relativeCandidates: readonly string[]
+): Promise {
+ return await probeWorkspaceLocalExecutable(
+ runtime,
+ runtime.getWorkspacePath(projectPath, workspaceName),
+ relativeCandidates
+ );
+}
+
+export async function probeWorkspaceLocalPath(
+ runtime: Runtime,
+ workspacePath: string,
+ relativeCandidates: readonly string[]
+): Promise {
+ for (const relativeCandidate of relativeCandidates) {
+ const normalizedCandidatePath = runtime.normalizePath(relativeCandidate, workspacePath);
+
+ try {
+ const resolvedCandidatePath = await runtime.resolvePath(normalizedCandidatePath);
+ await runtime.stat(resolvedCandidatePath);
+ return resolvedCandidatePath;
+ } catch {
+ // Keep scanning candidates until one resolves.
+ }
+ }
+
+ return null;
+}
+
+export async function probeWorkspaceLocalPathInAncestors(
+ runtime: Runtime,
+ rootPath: string,
+ workspacePath: string,
+ relativeCandidates: readonly string[]
+): Promise {
+ for (const searchPath of getAncestorSearchPaths(rootPath, workspacePath)) {
+ const resolvedCandidatePath = await probeWorkspaceLocalPath(
+ runtime,
+ searchPath,
+ relativeCandidates
+ );
+ if (resolvedCandidatePath) {
+ return resolvedCandidatePath;
+ }
+ }
+
+ return null;
+}
+
+export function getManagedLspToolsDir(runtime: Runtime, ...segments: string[]): string {
+ return joinRuntimePath(runtime.getMuxHome(), "tools", "lsp", ...segments);
+}
+
+export async function ensureManagedLspToolsDir(
+ runtime: Runtime,
+ ...segments: string[]
+): Promise {
+ const directoryPath = getManagedLspToolsDir(runtime, ...segments);
+ await runtime.ensureDir(directoryPath);
+ return directoryPath;
+}
+
+export async function resolveNodePackageManagerOrder(
+ runtime: Runtime,
+ rootPath: string,
+ workspacePath: string,
+ explicitManagers?: readonly LspNodePackageManager[]
+): Promise {
+ if (explicitManagers && explicitManagers.length > 0) {
+ return explicitManagers;
+ }
+
+ const preferredManagers: LspNodePackageManager[] = [];
+ for (const searchPath of getAncestorSearchPaths(rootPath, workspacePath)) {
+ const packageManagerField = await readWorkspacePackageManagerField(runtime, searchPath);
+ if (packageManagerField) {
+ preferredManagers.push(packageManagerField);
+ }
+
+ for (const [lockfileName, manager] of [
+ ["bun.lock", "bunx"],
+ ["bun.lockb", "bunx"],
+ ["pnpm-lock.yaml", "pnpm"],
+ ["package-lock.json", "npm"],
+ ] as const) {
+ if (await pathExists(runtime, runtime.normalizePath(lockfileName, searchPath))) {
+ preferredManagers.push(manager);
+ }
+ }
+ }
+
+ return dedupeNodePackageManagers([...preferredManagers, ...DEFAULT_NODE_PACKAGE_MANAGERS]);
+}
+
+export async function resolveNodePackageExecCommand(
+ runtime: Runtime,
+ rootPath: string,
+ workspacePath: string,
+ cwd: string,
+ env: Readonly> | undefined,
+ strategy: LspNodePackageExecStrategy,
+ policyContext: LspPolicyContext,
+ fallbackPackageNames?: readonly string[]
+): Promise<{ command: string; args: readonly string[] } | { reason: string }> {
+ if (!policyContext.trustedWorkspaceExecution) {
+ return {
+ reason: "automatic package-manager provisioning is disabled for untrusted workspaces",
+ };
+ }
+ if (policyContext.provisioningMode !== "auto") {
+ return {
+ reason: `automatic package-manager provisioning is disabled in ${policyContext.provisioningMode} mode`,
+ };
+ }
+
+ const packageManagers = await resolveNodePackageManagerOrder(
+ runtime,
+ rootPath,
+ workspacePath,
+ strategy.packageManagers
+ );
+
+ for (const packageManager of packageManagers) {
+ const packageManagerCommand = await resolveNodePackageManagerCommand(
+ runtime,
+ packageManager,
+ cwd,
+ env
+ );
+ if (!packageManagerCommand) {
+ continue;
+ }
+
+ return {
+ command: packageManagerCommand,
+ args: buildNodePackageManagerExecArgs(packageManager, strategy, fallbackPackageNames),
+ };
+ }
+
+ return {
+ reason: `none of the supported package managers are available on PATH (${packageManagers.join(", ")})`,
+ };
+}
+
+export async function ensureManagedGoTool(
+ runtime: Runtime,
+ cwd: string,
+ env: Readonly> | undefined,
+ strategy: LspGoManagedInstallStrategy,
+ policyContext: LspPolicyContext
+): Promise<{ command: string } | { reason: string }> {
+ if (!policyContext.trustedWorkspaceExecution) {
+ return {
+ reason: "managed Go installs are disabled for untrusted workspaces",
+ };
+ }
+ if (policyContext.provisioningMode !== "auto") {
+ return {
+ reason: `managed Go installs are disabled in ${policyContext.provisioningMode} mode`,
+ };
+ }
+
+ const installDirectory = await ensureManagedLspToolsDir(
+ runtime,
+ ...(strategy.installSubdirectory ?? ["go", "bin"])
+ );
+ const resolvedInstallDirectory = await runtime
+ .resolvePath(installDirectory)
+ .catch(() => installDirectory);
+ const installEnv = {
+ ...(env ?? {}),
+ GOBIN: resolvedInstallDirectory,
+ };
+ const existingManagedBinary = await resolveManagedGoBinary(
+ runtime,
+ cwd,
+ installEnv,
+ resolvedInstallDirectory,
+ strategy.binaryName
+ );
+ if (existingManagedBinary) {
+ return { command: existingManagedBinary };
+ }
+
+ const goCommand = await probeCommandOnPath(runtime, "go", cwd, env);
+ if (!goCommand) {
+ return { reason: "go is not available on PATH for managed gopls installation" };
+ }
+
+ const installResult = await execProbe(
+ runtime,
+ `${shellQuote(goCommand)} install ${shellQuote(strategy.module)}`,
+ {
+ cwd,
+ env: installEnv,
+ timeout: 120,
+ }
+ );
+ if (installResult.exitCode !== 0) {
+ const detail = installResult.stderr.trim() || installResult.stdout.trim();
+ return {
+ reason: detail
+ ? `failed to install ${strategy.binaryName} via go install: ${detail}`
+ : `failed to install ${strategy.binaryName} via go install`,
+ };
+ }
+
+ const resolvedBinaryPath = await resolveManagedGoBinary(
+ runtime,
+ cwd,
+ installEnv,
+ resolvedInstallDirectory,
+ strategy.binaryName
+ );
+ if (!resolvedBinaryPath) {
+ const installedBinaryPath = joinRuntimePath(resolvedInstallDirectory, strategy.binaryName);
+ return {
+ reason: `${strategy.binaryName} was installed but the managed binary is not runnable at ${installedBinaryPath}`,
+ };
+ }
+
+ return { command: resolvedBinaryPath };
+}
+
+async function resolveManagedGoBinary(
+ runtime: Runtime,
+ cwd: string,
+ env: Readonly>,
+ installDirectory: string,
+ binaryName: string
+): Promise {
+ return await resolveExecutablePathCandidate(
+ runtime,
+ joinRuntimePath(installDirectory, binaryName),
+ cwd,
+ env
+ );
+}
+
+async function resolveNodePackageManagerCommand(
+ runtime: Runtime,
+ packageManager: LspNodePackageManager,
+ cwd: string,
+ env?: Readonly>
+): Promise {
+ switch (packageManager) {
+ case "bunx":
+ return await probeCommandOnPath(runtime, "bunx", cwd, env);
+ case "pnpm":
+ return await probeCommandOnPath(runtime, "pnpm", cwd, env);
+ case "npm":
+ return await probeCommandOnPath(runtime, "npm", cwd, env);
+ }
+}
+
+function buildNodePackageManagerExecArgs(
+ packageManager: LspNodePackageManager,
+ strategy: LspNodePackageExecStrategy,
+ fallbackPackageNames?: readonly string[]
+): readonly string[] {
+ const packageNames = dedupePackageNames([strategy.packageName, ...(fallbackPackageNames ?? [])]);
+
+ switch (packageManager) {
+ case "bunx":
+ return [
+ ...packageNames.flatMap((packageName) => ["--package", packageName]),
+ strategy.binaryName,
+ ];
+ case "pnpm":
+ return [
+ ...packageNames.flatMap((packageName) => ["--package", packageName]),
+ "dlx",
+ strategy.binaryName,
+ ];
+ case "npm":
+ return [
+ "exec",
+ ...packageNames.map((packageName) => `--package=${packageName}`),
+ "--",
+ strategy.binaryName,
+ ];
+ }
+}
+
+async function readWorkspacePackageManagerField(
+ runtime: Runtime,
+ rootPath: string
+): Promise {
+ const packageJsonPath = runtime.normalizePath("package.json", rootPath);
+ if (!(await pathExists(runtime, packageJsonPath))) {
+ return null;
+ }
+
+ try {
+ const packageJson = JSON.parse(await readFileString(runtime, packageJsonPath)) as {
+ packageManager?: unknown;
+ };
+ return parseNodePackageManager(packageJson.packageManager);
+ } catch {
+ return null;
+ }
+}
+
+function parseNodePackageManager(value: unknown): LspNodePackageManager | null {
+ if (typeof value !== "string") {
+ return null;
+ }
+
+ const [name] = value.trim().toLowerCase().split("@");
+ if (name === "bun") {
+ return "bunx";
+ }
+ if (name === "pnpm") {
+ return "pnpm";
+ }
+ if (name === "npm") {
+ return "npm";
+ }
+ return null;
+}
+
+function dedupeNodePackageManagers(
+ managers: readonly LspNodePackageManager[]
+): readonly LspNodePackageManager[] {
+ const deduped: LspNodePackageManager[] = [];
+ for (const manager of managers) {
+ if (!deduped.includes(manager)) {
+ deduped.push(manager);
+ }
+ }
+ return deduped;
+}
+
+function dedupePackageNames(packageNames: readonly string[]): readonly string[] {
+ const deduped: string[] = [];
+ for (const packageName of packageNames) {
+ if (!deduped.includes(packageName)) {
+ deduped.push(packageName);
+ }
+ }
+ return deduped;
+}
+
+function getAncestorSearchPaths(rootPath: string, workspacePath: string): readonly string[] {
+ const resolvedRootPath = path.resolve(rootPath);
+ const resolvedWorkspacePath = path.resolve(workspacePath);
+ if (!isPathInsideDir(resolvedWorkspacePath, resolvedRootPath)) {
+ return [resolvedRootPath];
+ }
+
+ const searchPaths: string[] = [];
+ let currentPath = resolvedRootPath;
+ while (true) {
+ searchPaths.push(currentPath);
+ if (currentPath === resolvedWorkspacePath) {
+ return searchPaths;
+ }
+
+ const parentPath = path.dirname(currentPath);
+ if (parentPath === currentPath) {
+ return searchPaths;
+ }
+ currentPath = parentPath;
+ }
+}
+
+async function execProbe(
+ runtime: Runtime,
+ command: string,
+ options: ExecOptions
+): Promise<{ stdout: string; stderr: string; exitCode: number }> {
+ const stream = await runtime.exec(command, options);
+ try {
+ await stream.stdin.close();
+ } catch {
+ // Probes do not write to stdin, and some runtimes can close the stream before callers do.
+ }
+
+ const [stdout, stderr, exitCode] = await Promise.all([
+ streamToString(stream.stdout),
+ streamToString(stream.stderr),
+ stream.exitCode,
+ ]);
+ return { stdout, stderr, exitCode };
+}
+
+async function streamToString(stream: ReadableStream): Promise {
+ const reader = stream.getReader();
+ const decoder = new TextDecoder("utf-8");
+ const chunks: string[] = [];
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+ chunks.push(decoder.decode(value, { stream: true }));
+ }
+ const tail = decoder.decode();
+ if (tail) {
+ chunks.push(tail);
+ }
+ return chunks.join("");
+ } finally {
+ reader.releaseLock();
+ }
+}
+
+async function isRunnablePath(
+ runtime: Runtime,
+ filePath: string,
+ cwd: string,
+ env?: Readonly>
+): Promise {
+ const result = await execProbe(runtime, `test -x ${shellQuote(filePath)}`, {
+ cwd,
+ ...(env ? { env: { ...env } } : {}),
+ timeout: LSP_PROBE_TIMEOUT_SECONDS,
+ });
+ return result.exitCode === 0;
+}
+
+async function pathExists(runtime: Runtime, candidatePath: string): Promise {
+ try {
+ await runtime.stat(candidatePath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function joinRuntimePath(basePath: string, ...segments: string[]): string {
+ const pathModule = selectPathModule(basePath);
+ return pathModule.join(basePath, ...segments);
+}
+
+type PathModule = typeof path.posix;
+
+function selectPathModule(filePath: string): PathModule {
+ if (/^[A-Za-z]:[\\/]/u.test(filePath) || filePath.includes("\\")) {
+ return path.win32;
+ }
+ return path.posix;
+}
diff --git a/src/node/services/lsp/lspLaunchResolver.test.ts b/src/node/services/lsp/lspLaunchResolver.test.ts
new file mode 100644
index 0000000000..96b76ffd3f
--- /dev/null
+++ b/src/node/services/lsp/lspLaunchResolver.test.ts
@@ -0,0 +1,875 @@
+import * as fs from "node:fs/promises";
+import * as os from "node:os";
+import * as path from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "bun:test";
+import { DevcontainerRuntime } from "@/node/runtime/DevcontainerRuntime";
+import { DockerRuntime } from "@/node/runtime/DockerRuntime";
+import { LocalRuntime } from "@/node/runtime/LocalRuntime";
+import { WorktreeRuntime } from "@/node/runtime/WorktreeRuntime";
+import { ContainerManager } from "@/node/multiProject/containerManager";
+import { MultiProjectRuntime } from "@/node/runtime/multiProjectRuntime";
+import {
+ getManagedLspToolsDir,
+ probeWorkspaceLocalExecutable,
+ probeWorkspaceLocalExecutableForWorkspace,
+} from "./lspLaunchProvisioning";
+import { getPathCommandEnv, resolveLspLaunchPlan } from "./lspLaunchResolver";
+import type { LspPolicyContext, LspServerDescriptor } from "./types";
+
+const TRUSTED_MANUAL_POLICY_CONTEXT: LspPolicyContext = {
+ provisioningMode: "manual",
+ trustedWorkspaceExecution: true,
+};
+const TRUSTED_AUTO_POLICY_CONTEXT: LspPolicyContext = {
+ provisioningMode: "auto",
+ trustedWorkspaceExecution: true,
+};
+const UNTRUSTED_AUTO_POLICY_CONTEXT: LspPolicyContext = {
+ provisioningMode: "auto",
+ trustedWorkspaceExecution: false,
+};
+
+function createManualDescriptor(command: string): LspServerDescriptor {
+ return {
+ id: "typescript",
+ extensions: [".ts"],
+ launch: {
+ type: "manual",
+ command,
+ args: ["--stdio"],
+ },
+ rootMarkers: ["package.json", ".git"],
+ languageIdForPath: () => "typescript",
+ };
+}
+
+function prependToPath(entry: string): string {
+ return [entry, process.env.PATH]
+ .filter((value): value is string => value != null && value.length > 0)
+ .join(path.delimiter);
+}
+
+async function writeExecutable(filePath: string, script: string): Promise {
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.writeFile(filePath, script);
+ await fs.chmod(filePath, 0o755);
+}
+
+describe("resolveLspLaunchPlan", () => {
+ let workspacePath: string;
+ let runtime: LocalRuntime;
+ let binDir: string;
+
+ beforeEach(async () => {
+ workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "mux-lsp-launch-"));
+ runtime = new LocalRuntime(workspacePath);
+ binDir = path.join(workspacePath, "tools", "bin");
+
+ await fs.mkdir(path.join(workspacePath, "subdir"), { recursive: true });
+ await fs.mkdir(path.join(workspacePath, ".git"), { recursive: true });
+ await fs.writeFile(path.join(workspacePath, "package.json"), "{}\n");
+ });
+
+ afterEach(async () => {
+ await fs.rm(workspacePath, { recursive: true, force: true });
+ });
+
+ it("resolves explicit relative executables and launch cwd before client creation", async () => {
+ await writeExecutable(
+ path.join(workspacePath, "tools", "bin", "fake-lsp"),
+ "#!/bin/sh\nexit 0\n"
+ );
+
+ const descriptor: LspServerDescriptor = {
+ ...createManualDescriptor("../tools/bin/fake-lsp"),
+ launch: {
+ type: "manual",
+ command: "../tools/bin/fake-lsp",
+ args: ["--stdio"],
+ cwd: "./subdir",
+ env: { LSP_TRACE: "verbose" },
+ initializationOptions: { preferences: { quoteStyle: "single" } },
+ },
+ };
+
+ const launchPlan = await resolveLspLaunchPlan({
+ descriptor,
+ runtime,
+ rootPath: workspacePath,
+ policyContext: TRUSTED_MANUAL_POLICY_CONTEXT,
+ });
+
+ expect(launchPlan).toEqual({
+ command: path.join(workspacePath, "tools", "bin", "fake-lsp"),
+ args: ["--stdio"],
+ cwd: path.join(workspacePath, "subdir"),
+ env: { LSP_TRACE: "verbose" },
+ initializationOptions: { preferences: { quoteStyle: "single" } },
+ });
+ });
+
+ it("prefers trusted workspace-local TypeScript server and injects project tsserver path", async () => {
+ await writeExecutable(
+ path.join(workspacePath, "node_modules", ".bin", "typescript-language-server"),
+ "#!/bin/sh\nexit 0\n"
+ );
+ await fs.mkdir(path.join(workspacePath, "node_modules", "typescript", "lib"), {
+ recursive: true,
+ });
+ await fs.writeFile(
+ path.join(workspacePath, "node_modules", "typescript", "lib", "tsserver.js"),
+ "module.exports = {};\n"
+ );
+ await writeExecutable(path.join(binDir, "typescript-language-server"), "#!/bin/sh\nexit 0\n");
+
+ const launchPlan = await resolveLspLaunchPlan({
+ descriptor: {
+ id: "typescript",
+ extensions: [".ts"],
+ launch: {
+ type: "provisioned",
+ args: ["--stdio"],
+ env: { PATH: prependToPath(binDir) },
+ workspaceTsserverPathCandidates: ["node_modules/typescript/lib"],
+ strategies: [
+ {
+ type: "workspaceLocalExecutable",
+ relativeCandidates: ["node_modules/.bin/typescript-language-server"],
+ },
+ { type: "pathCommand", command: "typescript-language-server" },
+ ],
+ },
+ rootMarkers: ["package.json", ".git"],
+ languageIdForPath: () => "typescript",
+ },
+ runtime,
+ rootPath: workspacePath,
+ policyContext: TRUSTED_MANUAL_POLICY_CONTEXT,
+ });
+
+ expect(launchPlan.command).toBe(
+ path.join(workspacePath, "node_modules", ".bin", "typescript-language-server")
+ );
+ expect(launchPlan.args).toEqual(["--stdio"]);
+ expect(launchPlan.initializationOptions).toEqual({
+ tsserver: {
+ path: path.join(workspacePath, "node_modules", "typescript", "lib"),
+ },
+ });
+ });
+
+ it("skips workspace-local probes for untrusted workspaces and falls back to a sanitized PATH", async () => {
+ const externalBinDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-lsp-global-bin-"));
+ try {
+ await writeExecutable(
+ path.join(workspacePath, "node_modules", ".bin", "typescript-language-server"),
+ "#!/bin/sh\nexit 0\n"
+ );
+ await fs.mkdir(path.join(workspacePath, "node_modules", "typescript", "lib"), {
+ recursive: true,
+ });
+ await fs.writeFile(
+ path.join(workspacePath, "node_modules", "typescript", "lib", "tsserver.js"),
+ "module.exports = {};\n"
+ );
+ await writeExecutable(
+ path.join(externalBinDir, "typescript-language-server"),
+ "#!/bin/sh\nexit 0\n"
+ );
+
+ const launchPlan = await resolveLspLaunchPlan({
+ descriptor: {
+ id: "typescript",
+ extensions: [".ts"],
+ launch: {
+ type: "provisioned",
+ args: ["--stdio"],
+ env: {
+ PATH: ["node_modules/.bin", prependToPath(externalBinDir)]
+ .filter((value) => value.length > 0)
+ .join(path.delimiter),
+ },
+ workspaceTsserverPathCandidates: ["node_modules/typescript/lib"],
+ strategies: [
+ {
+ type: "workspaceLocalExecutable",
+ relativeCandidates: ["node_modules/.bin/typescript-language-server"],
+ },
+ { type: "pathCommand", command: "typescript-language-server" },
+ ],
+ },
+ rootMarkers: ["package.json", ".git"],
+ languageIdForPath: () => "typescript",
+ },
+ runtime,
+ rootPath: workspacePath,
+ policyContext: UNTRUSTED_AUTO_POLICY_CONTEXT,
+ });
+
+ expect(launchPlan.command).toBe(path.join(externalBinDir, "typescript-language-server"));
+ expect(launchPlan.env).toEqual({ PATH: prependToPath(externalBinDir) });
+ expect(launchPlan.initializationOptions).toBeUndefined();
+ } finally {
+ await fs.rm(externalBinDir, { recursive: true, force: true });
+ }
+ });
+
+ it("uses an explicit sanitized inherited PATH for untrusted pathCommand launches", async () => {
+ const externalBinDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-lsp-global-bin-"));
+ const workspaceBinDir = path.join(workspacePath, "node_modules", ".bin");
+ const originalPath = process.env.PATH;
+
+ try {
+ await writeExecutable(
+ path.join(workspaceBinDir, "mux-untrusted-path-command"),
+ "#!/bin/sh\nexit 0\n"
+ );
+ await writeExecutable(
+ path.join(externalBinDir, "mux-untrusted-path-command"),
+ "#!/bin/sh\nexit 0\n"
+ );
+
+ process.env.PATH = [workspaceBinDir, externalBinDir, originalPath]
+ .filter((value): value is string => value != null && value.length > 0)
+ .join(path.delimiter);
+
+ const launchPlan = await resolveLspLaunchPlan({
+ descriptor: {
+ id: "typescript",
+ extensions: [".ts"],
+ launch: {
+ type: "provisioned",
+ args: ["--stdio"],
+ env: { LSP_TRACE: "verbose" },
+ strategies: [{ type: "pathCommand", command: "mux-untrusted-path-command" }],
+ },
+ rootMarkers: ["package.json", ".git"],
+ languageIdForPath: () => "typescript",
+ },
+ runtime,
+ rootPath: workspacePath,
+ policyContext: UNTRUSTED_AUTO_POLICY_CONTEXT,
+ });
+
+ expect(launchPlan.command).toBe(path.join(externalBinDir, "mux-untrusted-path-command"));
+ expect(launchPlan.env).toEqual({
+ LSP_TRACE: "verbose",
+ PATH: [externalBinDir, originalPath]
+ .filter((value): value is string => value != null && value.length > 0)
+ .join(path.delimiter),
+ });
+ } finally {
+ if (originalPath === undefined) {
+ delete process.env.PATH;
+ } else {
+ process.env.PATH = originalPath;
+ }
+ await fs.rm(externalBinDir, { recursive: true, force: true });
+ }
+ });
+
+ it("keeps a sanitized PATH override for untrusted pathCommand launches without explicit env", async () => {
+ const externalBinDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-lsp-global-bin-"));
+ const workspaceBinDir = path.join(workspacePath, "node_modules", ".bin");
+ const originalPath = process.env.PATH;
+
+ try {
+ await writeExecutable(
+ path.join(workspaceBinDir, "mux-inherited-path-command"),
+ "#!/bin/sh\nexit 0\n"
+ );
+ await writeExecutable(
+ path.join(externalBinDir, "mux-inherited-path-command"),
+ "#!/bin/sh\nexit 0\n"
+ );
+
+ process.env.PATH = [workspaceBinDir, externalBinDir, originalPath]
+ .filter((value): value is string => value != null && value.length > 0)
+ .join(path.delimiter);
+
+ const launchPlan = await resolveLspLaunchPlan({
+ descriptor: {
+ id: "typescript",
+ extensions: [".ts"],
+ launch: {
+ type: "provisioned",
+ args: ["--stdio"],
+ strategies: [{ type: "pathCommand", command: "mux-inherited-path-command" }],
+ },
+ rootMarkers: ["package.json", ".git"],
+ languageIdForPath: () => "typescript",
+ },
+ runtime,
+ rootPath: workspacePath,
+ policyContext: UNTRUSTED_AUTO_POLICY_CONTEXT,
+ });
+
+ expect(launchPlan.command).toBe(path.join(externalBinDir, "mux-inherited-path-command"));
+ expect(launchPlan.env).toEqual({
+ PATH: [externalBinDir, originalPath]
+ .filter((value): value is string => value != null && value.length > 0)
+ .join(path.delimiter),
+ });
+ } finally {
+ if (originalPath === undefined) {
+ delete process.env.PATH;
+ } else {
+ process.env.PATH = originalPath;
+ }
+ await fs.rm(externalBinDir, { recursive: true, force: true });
+ }
+ });
+
+ it("sanitizes untrusted inherited PATH entries from the full workspace for nested package roots", async () => {
+ const packageRoot = path.join(workspacePath, "apps", "web");
+ const externalBinDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-lsp-global-bin-"));
+ const repoBinDir = path.join(workspacePath, "node_modules", ".bin");
+ const originalPath = process.env.PATH;
+
+ try {
+ await fs.mkdir(packageRoot, { recursive: true });
+ await fs.writeFile(path.join(packageRoot, "package.json"), "{}\n");
+ await writeExecutable(
+ path.join(repoBinDir, "mux-nested-path-command"),
+ "#!/bin/sh\nexit 0\n"
+ );
+ await writeExecutable(
+ path.join(externalBinDir, "mux-nested-path-command"),
+ "#!/bin/sh\nexit 0\n"
+ );
+
+ process.env.PATH = [repoBinDir, externalBinDir, originalPath]
+ .filter((value): value is string => value != null && value.length > 0)
+ .join(path.delimiter);
+
+ const launchPlan = await resolveLspLaunchPlan({
+ descriptor: {
+ id: "typescript",
+ extensions: [".ts"],
+ launch: {
+ type: "provisioned",
+ args: ["--stdio"],
+ strategies: [{ type: "pathCommand", command: "mux-nested-path-command" }],
+ },
+ rootMarkers: ["package.json", ".git"],
+ languageIdForPath: () => "typescript",
+ },
+ runtime,
+ rootPath: packageRoot,
+ workspacePath,
+ policyContext: UNTRUSTED_AUTO_POLICY_CONTEXT,
+ });
+
+ expect(launchPlan.command).toBe(path.join(externalBinDir, "mux-nested-path-command"));
+ expect(launchPlan.env).toEqual({
+ PATH: [externalBinDir, originalPath]
+ .filter((value): value is string => value != null && value.length > 0)
+ .join(path.delimiter),
+ });
+ } finally {
+ if (originalPath === undefined) {
+ delete process.env.PATH;
+ } else {
+ process.env.PATH = originalPath;
+ }
+ await fs.rm(externalBinDir, { recursive: true, force: true });
+ }
+ });
+
+ it("sanitizes untrusted PATH env only for local and worktree runtimes", async () => {
+ const externalBinDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-lsp-global-bin-"));
+ const workspaceBinDir = path.join(workspacePath, "node_modules", ".bin");
+ const explicitPath = [workspaceBinDir, externalBinDir]
+ .filter((value) => value.length > 0)
+ .join(path.delimiter);
+ const expectedSanitizedPath = externalBinDir;
+
+ try {
+ const localEnv = getPathCommandEnv(
+ runtime,
+ workspacePath,
+ { LSP_TRACE: "verbose", PATH: explicitPath },
+ UNTRUSTED_AUTO_POLICY_CONTEXT
+ );
+ expect(localEnv).toEqual({
+ LSP_TRACE: "verbose",
+ PATH: expectedSanitizedPath,
+ });
+
+ const worktreeEnv = getPathCommandEnv(
+ new WorktreeRuntime(os.tmpdir()),
+ workspacePath,
+ { LSP_TRACE: "verbose", PATH: explicitPath },
+ UNTRUSTED_AUTO_POLICY_CONTEXT
+ );
+ expect(worktreeEnv).toEqual({
+ LSP_TRACE: "verbose",
+ PATH: expectedSanitizedPath,
+ });
+
+ const wrappedWorktreeEnv = getPathCommandEnv(
+ new MultiProjectRuntime(
+ new ContainerManager(os.tmpdir()),
+ [
+ {
+ projectPath: workspacePath,
+ projectName: "project",
+ runtime: new WorktreeRuntime(os.tmpdir()),
+ },
+ ],
+ "shared-workspace"
+ ),
+ workspacePath,
+ { LSP_TRACE: "verbose", PATH: explicitPath },
+ UNTRUSTED_AUTO_POLICY_CONTEXT
+ );
+ expect(wrappedWorktreeEnv).toEqual({
+ LSP_TRACE: "verbose",
+ PATH: expectedSanitizedPath,
+ });
+
+ const remotePath = ["node_modules/.bin", "/usr/local/bin"].join(path.delimiter);
+ const remoteEnv = { LSP_TRACE: "verbose", PATH: remotePath };
+
+ const wrappedDockerEnv = getPathCommandEnv(
+ new MultiProjectRuntime(
+ new ContainerManager(os.tmpdir()),
+ [
+ {
+ projectPath: workspacePath,
+ projectName: "project",
+ runtime: new DockerRuntime({ image: "node:20", containerName: "mux-lsp-test" }),
+ },
+ ],
+ "shared-workspace"
+ ),
+ workspacePath,
+ remoteEnv,
+ UNTRUSTED_AUTO_POLICY_CONTEXT
+ );
+ expect(wrappedDockerEnv).toEqual(remoteEnv);
+
+ const devcontainerEnv = getPathCommandEnv(
+ new DevcontainerRuntime({
+ srcBaseDir: os.tmpdir(),
+ configPath: path.join(workspacePath, ".devcontainer", "devcontainer.json"),
+ }),
+ workspacePath,
+ remoteEnv,
+ UNTRUSTED_AUTO_POLICY_CONTEXT
+ );
+ expect(devcontainerEnv).toEqual(remoteEnv);
+
+ const dockerEnv = getPathCommandEnv(
+ new DockerRuntime({ image: "node:20", containerName: "mux-lsp-test" }),
+ workspacePath,
+ remoteEnv,
+ UNTRUSTED_AUTO_POLICY_CONTEXT
+ );
+ expect(dockerEnv).toEqual(remoteEnv);
+ } finally {
+ await fs.rm(externalBinDir, { recursive: true, force: true });
+ }
+ });
+
+ it("orders package-manager execution using repo signals", async () => {
+ await writeExecutable(path.join(binDir, "bunx"), "#!/bin/sh\nexit 0\n");
+ await writeExecutable(path.join(binDir, "pnpm"), "#!/bin/sh\nexit 0\n");
+ await writeExecutable(path.join(binDir, "npm"), "#!/bin/sh\nexit 0\n");
+ await fs.writeFile(
+ path.join(workspacePath, "package.json"),
+ JSON.stringify({ packageManager: "bun@1.2.0" })
+ );
+ await fs.writeFile(path.join(workspacePath, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
+
+ const launchPlan = await resolveLspLaunchPlan({
+ descriptor: {
+ id: "typescript",
+ extensions: [".ts"],
+ launch: {
+ type: "provisioned",
+ args: ["--stdio"],
+ env: { PATH: prependToPath(binDir) },
+ strategies: [
+ {
+ type: "nodePackageExec",
+ packageName: "typescript-language-server",
+ binaryName: "typescript-language-server",
+ },
+ ],
+ },
+ rootMarkers: ["package.json", ".git"],
+ languageIdForPath: () => "typescript",
+ },
+ runtime,
+ rootPath: workspacePath,
+ policyContext: TRUSTED_AUTO_POLICY_CONTEXT,
+ });
+
+ expect(launchPlan).toEqual({
+ command: path.join(binDir, "bunx"),
+ args: ["--package", "typescript-language-server", "typescript-language-server", "--stdio"],
+ cwd: workspacePath,
+ env: { PATH: prependToPath(binDir) },
+ initializationOptions: undefined,
+ });
+ });
+
+ it("finds ancestor TypeScript metadata and package-manager hints for nested package roots", async () => {
+ const packageRoot = path.join(workspacePath, "web", "packages", "teleport");
+ const repoTypescriptLib = path.join(workspacePath, "node_modules", "typescript", "lib");
+
+ await fs.mkdir(packageRoot, { recursive: true });
+ await fs.writeFile(path.join(packageRoot, "package.json"), "{}\n");
+ await fs.writeFile(
+ path.join(workspacePath, "package.json"),
+ JSON.stringify({ packageManager: "pnpm@9.0.0" })
+ );
+ await fs.writeFile(path.join(workspacePath, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
+ await fs.mkdir(repoTypescriptLib, { recursive: true });
+ await fs.writeFile(path.join(repoTypescriptLib, "tsserver.js"), "module.exports = {};\n");
+ await writeExecutable(path.join(binDir, "pnpm"), "#!/bin/sh\nexit 0\n");
+
+ const launchPlan = await resolveLspLaunchPlan({
+ descriptor: {
+ id: "typescript",
+ extensions: [".ts"],
+ launch: {
+ type: "provisioned",
+ args: ["--stdio"],
+ env: { PATH: prependToPath(binDir) },
+ workspaceTsserverPathCandidates: ["node_modules/typescript/lib"],
+ strategies: [
+ {
+ type: "nodePackageExec",
+ packageName: "typescript-language-server",
+ binaryName: "typescript-language-server",
+ fallbackPackageNames: ["typescript"],
+ },
+ ],
+ },
+ rootMarkers: ["package.json", ".git"],
+ languageIdForPath: () => "typescript",
+ },
+ runtime,
+ rootPath: packageRoot,
+ workspacePath,
+ policyContext: TRUSTED_AUTO_POLICY_CONTEXT,
+ });
+
+ expect(launchPlan).toEqual({
+ command: path.join(binDir, "pnpm"),
+ args: [
+ "--package",
+ "typescript-language-server",
+ "dlx",
+ "typescript-language-server",
+ "--stdio",
+ ],
+ cwd: packageRoot,
+ env: { PATH: prependToPath(binDir) },
+ initializationOptions: {
+ tsserver: {
+ path: repoTypescriptLib,
+ },
+ },
+ });
+ });
+
+ it("adds a fallback TypeScript package when package-manager exec has no ancestor tsserver", async () => {
+ const packageRoot = path.join(workspacePath, "web", "packages", "teleport");
+
+ await fs.mkdir(packageRoot, { recursive: true });
+ await fs.writeFile(path.join(packageRoot, "package.json"), "{}\n");
+ await fs.writeFile(path.join(workspacePath, "package.json"), "{}\n");
+ await fs.writeFile(path.join(workspacePath, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
+ await writeExecutable(path.join(binDir, "pnpm"), "#!/bin/sh\nexit 0\n");
+
+ const launchPlan = await resolveLspLaunchPlan({
+ descriptor: {
+ id: "typescript",
+ extensions: [".ts"],
+ launch: {
+ type: "provisioned",
+ args: ["--stdio"],
+ env: { PATH: prependToPath(binDir) },
+ workspaceTsserverPathCandidates: ["node_modules/typescript/lib"],
+ strategies: [
+ {
+ type: "nodePackageExec",
+ packageName: "typescript-language-server",
+ binaryName: "typescript-language-server",
+ fallbackPackageNames: ["typescript"],
+ },
+ ],
+ },
+ rootMarkers: ["package.json", ".git"],
+ languageIdForPath: () => "typescript",
+ },
+ runtime,
+ rootPath: packageRoot,
+ workspacePath,
+ policyContext: TRUSTED_AUTO_POLICY_CONTEXT,
+ });
+
+ expect(launchPlan).toEqual({
+ command: path.join(binDir, "pnpm"),
+ args: [
+ "--package",
+ "typescript-language-server",
+ "--package",
+ "typescript",
+ "dlx",
+ "typescript-language-server",
+ "--stdio",
+ ],
+ cwd: packageRoot,
+ env: { PATH: prependToPath(binDir) },
+ initializationOptions: undefined,
+ });
+ });
+
+ it("disables automatic provisioning strategies for untrusted workspaces", async () => {
+ try {
+ await resolveLspLaunchPlan({
+ descriptor: {
+ id: "typescript",
+ extensions: [".ts"],
+ launch: {
+ type: "provisioned",
+ env: { PATH: prependToPath(binDir) },
+ strategies: [
+ {
+ type: "nodePackageExec",
+ packageName: "typescript-language-server",
+ binaryName: "typescript-language-server",
+ },
+ ],
+ },
+ rootMarkers: ["package.json", ".git"],
+ languageIdForPath: () => "typescript",
+ },
+ runtime,
+ rootPath: workspacePath,
+ policyContext: UNTRUSTED_AUTO_POLICY_CONTEXT,
+ });
+ throw new Error("Expected untrusted package-manager provisioning to be rejected");
+ } catch (error) {
+ expect(error).toBeInstanceOf(Error);
+ expect((error as Error).message).toContain(
+ "automatic package-manager provisioning is disabled for untrusted workspaces"
+ );
+ }
+
+ try {
+ await resolveLspLaunchPlan({
+ descriptor: {
+ id: "go",
+ extensions: [".go"],
+ launch: {
+ type: "provisioned",
+ env: { PATH: prependToPath(binDir) },
+ strategies: [
+ {
+ type: "goManagedInstall",
+ module: "golang.org/x/tools/gopls@v0.21.0",
+ binaryName: "gopls",
+ },
+ ],
+ },
+ rootMarkers: ["go.mod", ".git"],
+ languageIdForPath: () => "go",
+ },
+ runtime,
+ rootPath: workspacePath,
+ policyContext: UNTRUSTED_AUTO_POLICY_CONTEXT,
+ });
+ throw new Error("Expected untrusted managed Go provisioning to be rejected");
+ } catch (error) {
+ expect(error).toBeInstanceOf(Error);
+ expect((error as Error).message).toContain(
+ "managed Go installs are disabled for untrusted workspaces"
+ );
+ }
+ });
+
+ it("gates managed gopls installs on provisioning mode and installs in auto mode", async () => {
+ const installSubdirectory = ["tests", path.basename(workspacePath), "go", "bin"] as const;
+ await writeExecutable(
+ path.join(binDir, "go"),
+ '#!/bin/sh\nmkdir -p "$GOBIN"\nprintf \'#!/bin/sh\\nexit 0\\n\' > "$GOBIN/gopls"\nchmod +x "$GOBIN/gopls"\n'
+ );
+
+ const descriptor: LspServerDescriptor = {
+ id: "go",
+ extensions: [".go"],
+ launch: {
+ type: "provisioned",
+ env: { PATH: prependToPath(binDir) },
+ strategies: [
+ {
+ type: "goManagedInstall",
+ module: "golang.org/x/tools/gopls@v0.21.0",
+ binaryName: "gopls",
+ installSubdirectory,
+ },
+ ],
+ },
+ rootMarkers: ["go.mod", ".git"],
+ languageIdForPath: () => "go",
+ };
+
+ try {
+ await resolveLspLaunchPlan({
+ descriptor,
+ runtime,
+ rootPath: workspacePath,
+ policyContext: TRUSTED_MANUAL_POLICY_CONTEXT,
+ });
+ throw new Error("Expected manual provisioning mode to reject managed gopls installs");
+ } catch (error) {
+ expect(error).toBeInstanceOf(Error);
+ expect((error as Error).message).toContain("managed Go installs are disabled in manual mode");
+ }
+
+ const launchPlan = await resolveLspLaunchPlan({
+ descriptor,
+ runtime,
+ rootPath: workspacePath,
+ policyContext: TRUSTED_AUTO_POLICY_CONTEXT,
+ });
+
+ expect(launchPlan.command).toBe(
+ path.join(
+ await runtime.resolvePath(getManagedLspToolsDir(runtime, ...installSubdirectory)),
+ "gopls"
+ )
+ );
+ expect(launchPlan.cwd).toBe(workspacePath);
+ expect(launchPlan.env).toEqual({ PATH: prependToPath(binDir) });
+ });
+
+ it("reuses an existing managed gopls binary before re-running go install", async () => {
+ const installSubdirectory = [
+ "tests",
+ path.basename(workspacePath),
+ "reused-go",
+ "bin",
+ ] as const;
+ const managedBinDir = await runtime.resolvePath(
+ getManagedLspToolsDir(runtime, ...installSubdirectory)
+ );
+ const goInvocationMarker = path.join(workspacePath, "go-invoked.txt");
+
+ await writeExecutable(
+ path.join(binDir, "go"),
+ `#!/bin/sh\nprintf 'unexpected go install\\n' > ${JSON.stringify(goInvocationMarker)}\nexit 1\n`
+ );
+ await writeExecutable(path.join(managedBinDir, "gopls"), "#!/bin/sh\nexit 0\n");
+
+ const launchPlan = await resolveLspLaunchPlan({
+ descriptor: {
+ id: "go",
+ extensions: [".go"],
+ launch: {
+ type: "provisioned",
+ env: { PATH: prependToPath(binDir) },
+ strategies: [
+ {
+ type: "goManagedInstall",
+ module: "golang.org/x/tools/gopls@v0.21.0",
+ binaryName: "gopls",
+ installSubdirectory,
+ },
+ ],
+ },
+ rootMarkers: ["go.mod", ".git"],
+ languageIdForPath: () => "go",
+ },
+ runtime,
+ rootPath: workspacePath,
+ policyContext: TRUSTED_AUTO_POLICY_CONTEXT,
+ });
+
+ expect(launchPlan.command).toBe(path.join(managedBinDir, "gopls"));
+ try {
+ await fs.stat(goInvocationMarker);
+ throw new Error("Expected managed gopls reuse to avoid re-running go install");
+ } catch (error) {
+ expect(error).toMatchObject({ code: "ENOENT" });
+ }
+ });
+
+ it("returns unsupported errors for servers without auto-install support", async () => {
+ try {
+ await resolveLspLaunchPlan({
+ descriptor: {
+ id: "rust",
+ extensions: [".rs"],
+ launch: {
+ type: "provisioned",
+ strategies: [
+ {
+ type: "unsupported",
+ message:
+ "rust-analyzer is not available on PATH and automatic installation is not supported yet",
+ },
+ ],
+ },
+ rootMarkers: ["Cargo.toml", ".git"],
+ languageIdForPath: () => "rust",
+ },
+ runtime,
+ rootPath: workspacePath,
+ policyContext: TRUSTED_AUTO_POLICY_CONTEXT,
+ });
+ throw new Error("Expected rust-analyzer provisioning to report unsupported auto-install");
+ } catch (error) {
+ expect(error).toBeInstanceOf(Error);
+ expect((error as Error).message).toContain("automatic installation is not supported yet");
+ }
+ });
+});
+
+describe("lspLaunchProvisioning helpers", () => {
+ let workspacePath: string;
+
+ beforeEach(async () => {
+ workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "mux-lsp-provisioning-"));
+ await fs.mkdir(path.join(workspacePath, "node_modules", ".bin"), { recursive: true });
+ await fs.writeFile(
+ path.join(workspacePath, "node_modules", ".bin", "fake-lsp"),
+ "#!/bin/sh\nexit 0\n"
+ );
+ await fs.chmod(path.join(workspacePath, "node_modules", ".bin", "fake-lsp"), 0o755);
+ });
+
+ afterEach(async () => {
+ await fs.rm(workspacePath, { recursive: true, force: true });
+ });
+
+ it("probes workspace-local executable candidates with either workspace locator input", async () => {
+ const runtime = new LocalRuntime(workspacePath);
+ const relativeCandidates = ["node_modules/.bin/fake-lsp", "vendor/bin/fake-lsp"] as const;
+
+ expect(await probeWorkspaceLocalExecutable(runtime, workspacePath, relativeCandidates)).toBe(
+ path.join(workspacePath, "node_modules", ".bin", "fake-lsp")
+ );
+ expect(
+ await probeWorkspaceLocalExecutableForWorkspace(
+ runtime,
+ "/unused/project",
+ "feature",
+ relativeCandidates
+ )
+ ).toBe(path.join(workspacePath, "node_modules", ".bin", "fake-lsp"));
+ });
+
+ it("derives managed tool directories from mux home without touching PATH", () => {
+ const runtime = new LocalRuntime(workspacePath);
+
+ expect(getManagedLspToolsDir(runtime, "typescript")).toEndWith(
+ path.join(".mux", "tools", "lsp", "typescript")
+ );
+ });
+});
diff --git a/src/node/services/lsp/lspLaunchResolver.ts b/src/node/services/lsp/lspLaunchResolver.ts
new file mode 100644
index 0000000000..20a044529e
--- /dev/null
+++ b/src/node/services/lsp/lspLaunchResolver.ts
@@ -0,0 +1,369 @@
+import * as path from "node:path";
+import { LocalRuntime } from "@/node/runtime/LocalRuntime";
+import type { Runtime } from "@/node/runtime/Runtime";
+import { WorktreeRuntime } from "@/node/runtime/WorktreeRuntime";
+import { MultiProjectRuntime } from "@/node/runtime/multiProjectRuntime";
+import { isPathInsideDir } from "@/node/utils/pathUtils";
+import {
+ ensureManagedGoTool,
+ probeCommandOnPath,
+ probeWorkspaceLocalExecutable,
+ probeWorkspaceLocalPathInAncestors,
+ resolveExecutablePathCandidate,
+ resolveNodePackageExecCommand,
+} from "./lspLaunchProvisioning";
+import type {
+ LspManualLaunchPolicy,
+ LspPolicyContext,
+ LspProvisionedLaunchPolicy,
+ LspServerDescriptor,
+ ResolvedLspLaunchPlan,
+} from "./types";
+
+// Keep launch policy resolution outside LspClient so manager-side provisioning can evolve
+// without mixing discovery/installation concerns into stdio transport code.
+export interface ResolveLspLaunchPlanOptions {
+ descriptor: LspServerDescriptor;
+ runtime: Runtime;
+ rootPath: string;
+ workspacePath?: string;
+ policyContext: LspPolicyContext;
+}
+
+export async function resolveLspLaunchPlan(
+ options: ResolveLspLaunchPlanOptions
+): Promise {
+ switch (options.descriptor.launch.type) {
+ case "manual":
+ return await resolveManualLaunchPlan(
+ options.runtime,
+ options.rootPath,
+ options.descriptor.launch
+ );
+ case "provisioned":
+ return await resolveProvisionedLaunchPlan(options);
+ }
+}
+
+async function resolveManualLaunchPlan(
+ runtime: Runtime,
+ rootPath: string,
+ launchPolicy: LspManualLaunchPolicy
+): Promise {
+ const launchCwd = await resolveLaunchCwd(runtime, rootPath, launchPolicy.cwd);
+ const resolvedCommand = await resolveManualCommand(
+ runtime,
+ launchCwd,
+ launchPolicy.command,
+ launchPolicy.env
+ );
+
+ return {
+ command: resolvedCommand,
+ args: launchPolicy.args ?? [],
+ cwd: launchCwd,
+ env: launchPolicy.env,
+ initializationOptions: launchPolicy.initializationOptions,
+ };
+}
+
+async function resolveProvisionedLaunchPlan(
+ options: ResolveLspLaunchPlanOptions
+): Promise {
+ const launchPolicy = options.descriptor.launch;
+ if (launchPolicy.type !== "provisioned") {
+ throw new Error(`Expected a provisioned launch policy for ${options.descriptor.id}`);
+ }
+
+ const workspacePath = options.workspacePath ?? options.rootPath;
+ const launchCwd = await resolveLaunchCwd(options.runtime, options.rootPath, launchPolicy.cwd);
+ const workspaceTsserverPath = await resolveWorkspaceTsserverPath(
+ options.runtime,
+ options.rootPath,
+ workspacePath,
+ launchPolicy,
+ options.policyContext
+ );
+ const initializationOptions = mergeInitializationOptions(
+ launchPolicy.initializationOptions,
+ workspaceTsserverPath ? { tsserver: { path: workspaceTsserverPath } } : undefined
+ );
+
+ const failureReasons: string[] = [];
+ for (const strategy of launchPolicy.strategies) {
+ switch (strategy.type) {
+ case "workspaceLocalExecutable": {
+ if (!options.policyContext.trustedWorkspaceExecution) {
+ failureReasons.push(
+ `skipped trusted workspace-local executable probe (${strategy.relativeCandidates.join(", ")})`
+ );
+ break;
+ }
+
+ const resolvedCommand = await probeWorkspaceLocalExecutable(
+ options.runtime,
+ options.rootPath,
+ strategy.relativeCandidates
+ );
+ if (resolvedCommand) {
+ return {
+ command: resolvedCommand,
+ args: launchPolicy.args ?? [],
+ cwd: launchCwd,
+ env: launchPolicy.env,
+ initializationOptions,
+ };
+ }
+ failureReasons.push(
+ `workspace-local executable not found (${strategy.relativeCandidates.join(", ")})`
+ );
+ break;
+ }
+
+ case "pathCommand": {
+ const pathCommandEnv = getPathCommandEnv(
+ options.runtime,
+ workspacePath,
+ launchPolicy.env,
+ options.policyContext
+ );
+ const resolvedCommand = await probeCommandOnPath(
+ options.runtime,
+ strategy.command,
+ launchCwd,
+ pathCommandEnv
+ );
+ if (resolvedCommand) {
+ if (
+ !options.policyContext.trustedWorkspaceExecution &&
+ (await resolvesInsideWorkspace(
+ options.runtime,
+ resolvedCommand,
+ launchCwd,
+ options.rootPath
+ ))
+ ) {
+ failureReasons.push(
+ `skipped untrusted workspace-local PATH resolution for ${strategy.command}`
+ );
+ break;
+ }
+
+ return {
+ command: resolvedCommand,
+ args: launchPolicy.args ?? [],
+ cwd: launchCwd,
+ // LocalBaseRuntime merges plan env on top of process.env, so keeping the sanitized
+ // PATH override here still preserves HOME/TMPDIR/XDG_* while preventing repo-local
+ // PATH entries from leaking back into untrusted pathCommand launches.
+ env: pathCommandEnv,
+ initializationOptions,
+ };
+ }
+ failureReasons.push(`${strategy.command} is not available on PATH`);
+ break;
+ }
+
+ case "nodePackageExec": {
+ const result = await resolveNodePackageExecCommand(
+ options.runtime,
+ options.rootPath,
+ workspacePath,
+ launchCwd,
+ launchPolicy.env,
+ strategy,
+ options.policyContext,
+ workspaceTsserverPath == null ? strategy.fallbackPackageNames : undefined
+ );
+ if ("command" in result) {
+ return {
+ command: result.command,
+ args: [...result.args, ...(launchPolicy.args ?? [])],
+ cwd: launchCwd,
+ env: launchPolicy.env,
+ initializationOptions,
+ };
+ }
+ failureReasons.push(result.reason);
+ break;
+ }
+
+ case "goManagedInstall": {
+ const result = await ensureManagedGoTool(
+ options.runtime,
+ launchCwd,
+ launchPolicy.env,
+ strategy,
+ options.policyContext
+ );
+ if ("command" in result) {
+ return {
+ command: result.command,
+ args: launchPolicy.args ?? [],
+ cwd: launchCwd,
+ env: launchPolicy.env,
+ initializationOptions,
+ };
+ }
+ failureReasons.push(result.reason);
+ break;
+ }
+
+ case "unsupported":
+ failureReasons.push(strategy.message);
+ break;
+ }
+ }
+
+ throw new Error(
+ `Unable to resolve launch plan for ${options.descriptor.id} LSP server: ${failureReasons.join("; ")}`
+ );
+}
+
+async function resolveWorkspaceTsserverPath(
+ runtime: Runtime,
+ rootPath: string,
+ workspacePath: string,
+ launchPolicy: LspProvisionedLaunchPolicy,
+ policyContext: LspPolicyContext
+): Promise {
+ if (!policyContext.trustedWorkspaceExecution) {
+ return undefined;
+ }
+ if (!launchPolicy.workspaceTsserverPathCandidates) {
+ return undefined;
+ }
+
+ return (
+ (await probeWorkspaceLocalPathInAncestors(
+ runtime,
+ rootPath,
+ workspacePath,
+ launchPolicy.workspaceTsserverPathCandidates
+ )) ?? undefined
+ );
+}
+
+async function resolveLaunchCwd(
+ runtime: Runtime,
+ rootPath: string,
+ launchCwd: string | undefined
+): Promise {
+ const normalizedLaunchCwd =
+ launchCwd == null ? rootPath : runtime.normalizePath(launchCwd, rootPath);
+
+ try {
+ return await runtime.resolvePath(normalizedLaunchCwd);
+ } catch {
+ return normalizedLaunchCwd;
+ }
+}
+
+async function resolveManualCommand(
+ runtime: Runtime,
+ launchCwd: string,
+ command: string,
+ env?: Readonly>
+): Promise {
+ if (looksLikePathCandidate(command)) {
+ return (await resolveExecutablePathCandidate(runtime, command, launchCwd, env)) ?? command;
+ }
+
+ return (await probeCommandOnPath(runtime, command, launchCwd, env)) ?? command;
+}
+
+export function getPathCommandEnv(
+ runtime: Runtime,
+ workspacePath: string,
+ env: Readonly> | undefined,
+ policyContext: LspPolicyContext
+): Readonly> | undefined {
+ const effectiveRuntime = unwrapPrimaryRuntime(runtime);
+ if (
+ policyContext.trustedWorkspaceExecution ||
+ !(effectiveRuntime instanceof LocalRuntime || effectiveRuntime instanceof WorktreeRuntime)
+ ) {
+ return env;
+ }
+
+ const sourcePath = env?.PATH ?? process.env.PATH ?? "";
+ const sanitizedPath = sourcePath
+ .split(path.delimiter)
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0)
+ .filter((entry) => {
+ if (!path.isAbsolute(entry)) {
+ return false;
+ }
+ const resolvedEntry = path.resolve(entry);
+ return !isPathInsideDir(workspacePath, resolvedEntry);
+ })
+ .join(path.delimiter);
+
+ if (env?.PATH != null && sanitizedPath === env.PATH) {
+ return env;
+ }
+
+ return {
+ ...env,
+ // Only local/worktree runtimes merge env on the host, so they need a sanitized PATH override
+ // to keep untrusted workspaces from contributing repo-local entries to PATH resolution.
+ PATH: sanitizedPath,
+ };
+}
+
+function unwrapPrimaryRuntime(runtime: Runtime): Runtime {
+ return runtime instanceof MultiProjectRuntime ? runtime.getPrimaryRuntime() : runtime;
+}
+
+async function resolvesInsideWorkspace(
+ runtime: Runtime,
+ command: string,
+ launchCwd: string,
+ rootPath: string
+): Promise {
+ if (!looksLikePathCandidate(command)) {
+ return false;
+ }
+
+ const normalizedCommandPath = runtime.normalizePath(command, launchCwd);
+ const resolvedCommandPath = await runtime
+ .resolvePath(normalizedCommandPath)
+ .catch(() => normalizedCommandPath);
+ return isPathInsideDir(rootPath, resolvedCommandPath);
+}
+
+function mergeInitializationOptions(base: unknown, extra: unknown): unknown {
+ if (extra == null) {
+ return base;
+ }
+ if (base == null) {
+ return extra;
+ }
+ if (!isPlainObject(base) || !isPlainObject(extra)) {
+ return extra;
+ }
+
+ const merged: Record = { ...base };
+ for (const [key, value] of Object.entries(extra)) {
+ const existingValue = merged[key];
+ merged[key] =
+ isPlainObject(existingValue) && isPlainObject(value)
+ ? (mergeInitializationOptions(existingValue, value) as Record)
+ : value;
+ }
+ return merged;
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return typeof value === "object" && value != null && !Array.isArray(value);
+}
+
+function looksLikePathCandidate(command: string): boolean {
+ return (
+ command.includes("/") ||
+ command.includes("\\") ||
+ command.startsWith(".") ||
+ command.startsWith("~")
+ );
+}
diff --git a/src/node/services/lsp/lspManager.test.ts b/src/node/services/lsp/lspManager.test.ts
new file mode 100644
index 0000000000..afcc71806f
--- /dev/null
+++ b/src/node/services/lsp/lspManager.test.ts
@@ -0,0 +1,3595 @@
+import * as fs from "node:fs/promises";
+import * as os from "node:os";
+import * as path from "node:path";
+import { pathToFileURL } from "node:url";
+import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
+import type { WorkspaceLspDiagnosticsSnapshot } from "@/common/orpc/types";
+import { LocalRuntime } from "@/node/runtime/LocalRuntime";
+import type { ExecOptions, ExecStream } from "@/node/runtime/Runtime";
+import type {
+ CreateLspClientOptions,
+ LspClientInstance,
+ LspClientQueryResult,
+ LspDiagnostic,
+ LspServerDescriptor,
+} from "./types";
+import { LspManager } from "./lspManager";
+
+const TEST_LSP_POLICY_CONTEXT = {
+ provisioningMode: "manual" as const,
+ trustedWorkspaceExecution: true,
+};
+
+function createDeferred() {
+ let resolve!: (value: T | PromiseLike) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((promiseResolve, promiseReject) => {
+ resolve = promiseResolve;
+ reject = promiseReject;
+ });
+ return { promise, resolve, reject };
+}
+
+async function waitUntil(condition: () => boolean, timeoutMs = 2000): Promise {
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ if (condition()) {
+ return true;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ return false;
+}
+
+function createRegistry(): readonly LspServerDescriptor[] {
+ return [
+ {
+ id: "typescript",
+ extensions: [".ts"],
+ launch: {
+ type: "manual",
+ command: "mux-test-fake-lsp",
+ args: ["--stdio"],
+ },
+ rootMarkers: ["tsconfig.json", "jsconfig.json", "package.json", ".git"],
+ languageIdForPath: () => "typescript",
+ },
+ ];
+}
+
+function createManualDescriptor(
+ id: string,
+ extensions: readonly string[],
+ command: string,
+ rootMarkers: readonly string[]
+): LspServerDescriptor {
+ return {
+ id,
+ extensions,
+ launch: {
+ type: "manual",
+ command,
+ args: ["--stdio"],
+ },
+ rootMarkers,
+ languageIdForPath: () => id,
+ };
+}
+
+class CountingLocalRuntime extends LocalRuntime {
+ readonly pathProbeCommands: string[] = [];
+
+ override async exec(command: string, options: ExecOptions): Promise {
+ if (command.startsWith("command -v ")) {
+ this.pathProbeCommands.push(command);
+ }
+
+ return await super.exec(command, options);
+ }
+}
+
+function createDiagnostic(message: string): LspDiagnostic {
+ return {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 5 },
+ },
+ severity: 1,
+ source: "tsserver",
+ message,
+ };
+}
+
+function prependToPath(entry: string): string {
+ return [entry, process.env.PATH]
+ .filter((value): value is string => value != null && value.length > 0)
+ .join(path.delimiter);
+}
+
+function requireDirectoryWorkspaceSymbolsResults(result: Awaited>) {
+ expect(result.operation).toBe("workspace_symbols");
+ if (!("results" in result)) {
+ throw new Error("Expected a directory workspace_symbols result");
+ }
+
+ return result.results;
+}
+
+function requireSingleRootQueryResult(result: Awaited>) {
+ if ("results" in result) {
+ throw new Error("Expected a single-root LSP result");
+ }
+
+ return result;
+}
+
+const GO_EXACT_MATCH_WORKSPACE_SYMBOLS_ENV = "EXPERIMENT_LSP_GO_EXACT_MATCH_SYMBOLS";
+
+async function withGoExactMatchWorkspaceSymbolsEnv(
+ value: string | undefined,
+ run: () => Promise
+): Promise {
+ const previousValue = process.env[GO_EXACT_MATCH_WORKSPACE_SYMBOLS_ENV];
+ if (value == null) {
+ delete process.env[GO_EXACT_MATCH_WORKSPACE_SYMBOLS_ENV];
+ } else {
+ process.env[GO_EXACT_MATCH_WORKSPACE_SYMBOLS_ENV] = value;
+ }
+
+ try {
+ return await run();
+ } finally {
+ if (previousValue == null) {
+ delete process.env[GO_EXACT_MATCH_WORKSPACE_SYMBOLS_ENV];
+ } else {
+ process.env[GO_EXACT_MATCH_WORKSPACE_SYMBOLS_ENV] = previousValue;
+ }
+ }
+}
+
+describe("LspManager", () => {
+ let workspacePath: string;
+
+ beforeEach(async () => {
+ workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "mux-lsp-manager-"));
+ await fs.mkdir(path.join(workspacePath, ".git"));
+ await fs.mkdir(path.join(workspacePath, "src"), { recursive: true });
+ await fs.mkdir(path.join(workspacePath, "packages", "pkg", "src"), { recursive: true });
+ await fs.writeFile(path.join(workspacePath, "package.json"), "{}\n");
+ await fs.writeFile(path.join(workspacePath, "src", "example.ts"), "export const value = 1;\n");
+ await fs.writeFile(path.join(workspacePath, "packages", "pkg", "package.json"), "{}\n");
+ await fs.writeFile(
+ path.join(workspacePath, "packages", "pkg", "src", "nested.ts"),
+ "export const nested = 1;\n"
+ );
+ });
+
+ afterEach(async () => {
+ await fs.rm(workspacePath, { recursive: true, force: true });
+ });
+
+ async function queryGoWorkspaceSymbols(options: {
+ filePath: string;
+ envValue?: string;
+ }): Promise>> {
+ const goFilePath = path.join(workspacePath, "main.go");
+ await fs.writeFile(path.join(workspacePath, "go.mod"), "module example.com/mux-test\n");
+ await fs.writeFile(
+ goFilePath,
+ "package main\n\nfunc useAttemptHelper() {}\nfunc useAttempt() {}\nfunc attemptUse() {}\n"
+ );
+
+ const goDescriptor = createManualDescriptor("go", [".go"], "mux-test-go-lsp", [
+ "go.mod",
+ ".git",
+ ]);
+ const clientFactory = mock(
+ (clientOptions: CreateLspClientOptions): Promise => {
+ expect(clientOptions.descriptor.id).toBe("go");
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile: mock(() => Promise.resolve(1)),
+ query: mock(() =>
+ Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: [
+ {
+ name: "useAttemptHelper",
+ kind: 12,
+ location: {
+ uri: pathToFileURL(goFilePath).href,
+ range: {
+ start: { line: 2, character: 5 },
+ end: { line: 2, character: 21 },
+ },
+ },
+ },
+ {
+ name: "useAttempt",
+ kind: 12,
+ location: {
+ uri: pathToFileURL(goFilePath).href,
+ range: {
+ start: { line: 3, character: 5 },
+ end: { line: 3, character: 15 },
+ },
+ },
+ },
+ {
+ name: "attemptUse",
+ kind: 12,
+ location: {
+ uri: pathToFileURL(goFilePath).href,
+ range: {
+ start: { line: 4, character: 5 },
+ end: { line: 4, character: 15 },
+ },
+ },
+ },
+ ],
+ })
+ ),
+ close: mock(() => Promise.resolve(undefined)),
+ });
+ }
+ );
+
+ const manager = new LspManager({
+ registry: [goDescriptor],
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ try {
+ return await withGoExactMatchWorkspaceSymbolsEnv(
+ options.envValue,
+ async () =>
+ await manager.query({
+ workspaceId: "ws-go",
+ runtime,
+ workspacePath,
+ filePath: options.filePath,
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "workspace_symbols",
+ query: "useAttempt",
+ })
+ );
+ } finally {
+ await manager.dispose();
+ }
+ }
+
+ function createWorkspaceSymbol(name: string, filePath: string, line: number, kind = 12) {
+ return {
+ name,
+ kind,
+ location: {
+ uri: pathToFileURL(filePath).href,
+ range: {
+ start: { line, character: 0 },
+ end: { line, character: name.length },
+ },
+ },
+ };
+ }
+
+ async function queryMixedTypeScriptAndGoWorkspaceSymbols(options: {
+ query: string;
+ goSymbols: string[];
+ envValue?: string;
+ }): Promise>> {
+ const tsRootPath = path.join(workspacePath, "web", "packages", "teleport");
+ const tsFilePath = path.join(tsRootPath, "src", `${options.query}.ts`);
+ const goFilePath = path.join(workspacePath, "main.go");
+ await fs.mkdir(path.dirname(tsFilePath), { recursive: true });
+ await fs.writeFile(path.join(tsRootPath, "tsconfig.json"), "{}\n");
+ await fs.writeFile(tsFilePath, `export const ${options.query} = 1;\n`);
+ await fs.writeFile(path.join(workspacePath, "go.mod"), "module example.com/teleport\n");
+ await fs.writeFile(
+ goFilePath,
+ `package main\n\n${options.goSymbols.map((name) => `func ${name}() {}`).join("\n")}\n`
+ );
+
+ const goDescriptor = createManualDescriptor("go", [".go"], "mux-test-go-lsp", [
+ "go.mod",
+ ".git",
+ ]);
+ const clientFactory = mock(
+ (clientOptions: CreateLspClientOptions): Promise => {
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile: mock(() => Promise.resolve(1)),
+ query: mock(() => {
+ if (clientOptions.descriptor.id === "go") {
+ return Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: options.goSymbols.map((name, index) =>
+ createWorkspaceSymbol(name, goFilePath, index + 2)
+ ),
+ });
+ }
+
+ if (
+ clientOptions.descriptor.id === "typescript" &&
+ clientOptions.rootPath === tsRootPath
+ ) {
+ return Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: [createWorkspaceSymbol(options.query, tsFilePath, 0, 13)],
+ });
+ }
+
+ return Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: [],
+ });
+ }),
+ close: mock(() => Promise.resolve(undefined)),
+ });
+ }
+ );
+
+ const manager = new LspManager({
+ registry: [...createRegistry(), goDescriptor],
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ try {
+ return await withGoExactMatchWorkspaceSymbolsEnv(
+ options.envValue,
+ async () =>
+ await manager.query({
+ workspaceId: "ws-mixed-go-ts",
+ runtime,
+ workspacePath,
+ filePath: ".",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "workspace_symbols",
+ query: options.query,
+ })
+ );
+ } finally {
+ await manager.dispose();
+ }
+ }
+
+ async function queryTypeScriptFileBackedOperation(options: {
+ operation: "references" | "document_symbols";
+ buildRawResult(paths: { hookPath: string; usagePath: string }): LspClientQueryResult;
+ line?: number;
+ column?: number;
+ includeDeclaration?: boolean;
+ }): Promise<{
+ result: Awaited>;
+ clientFactoryOptions: CreateLspClientOptions;
+ ensurePaths: string[];
+ queryRequest: Parameters[0];
+ hookPath: string;
+ usagePath: string;
+ }> {
+ const hookPath = path.join(
+ workspacePath,
+ "web",
+ "packages",
+ "shared",
+ "hooks",
+ "useAttempt.ts"
+ );
+ const usagePath = path.join(workspacePath, "web", "packages", "app", "src", "consumer.ts");
+ await fs.mkdir(path.dirname(hookPath), { recursive: true });
+ await fs.mkdir(path.dirname(usagePath), { recursive: true });
+ await fs.writeFile(
+ path.join(workspacePath, "tsconfig.json"),
+ JSON.stringify({ include: ["web/**/*.ts"] }, null, 2) + "\n"
+ );
+ await fs.writeFile(
+ path.join(workspacePath, "web", "packages", "shared", "package.json"),
+ "{}\n"
+ );
+ await fs.writeFile(path.join(workspacePath, "web", "packages", "app", "package.json"), "{}\n");
+ await fs.writeFile(hookPath, "export function useAttempt() { return 1; }\n");
+ await fs.writeFile(
+ usagePath,
+ 'import { useAttempt } from "../../shared/hooks/useAttempt";\nexport const value = useAttempt();\n'
+ );
+
+ const ensurePaths: string[] = [];
+ let clientFactoryOptions: CreateLspClientOptions | undefined;
+ let queryRequest: Parameters[0] | undefined;
+ const clientFactory = mock(
+ (resolvedOptions: CreateLspClientOptions): Promise => {
+ clientFactoryOptions = resolvedOptions;
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile: mock((file: Parameters[0]) => {
+ ensurePaths.push(file.readablePath);
+ return Promise.resolve(1);
+ }),
+ query: mock((request: Parameters[0]) => {
+ queryRequest = request;
+ return Promise.resolve(options.buildRawResult({ hookPath, usagePath }));
+ }),
+ close: mock(() => Promise.resolve(undefined)),
+ });
+ }
+ );
+
+ const manager = new LspManager({
+ registry: createRegistry(),
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ try {
+ const result = await manager.query({
+ workspaceId: `ws-${options.operation}`,
+ runtime,
+ workspacePath,
+ filePath: "web/packages/shared/hooks/useAttempt.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: options.operation,
+ line: options.line,
+ column: options.column,
+ includeDeclaration: options.includeDeclaration,
+ });
+
+ if (!clientFactoryOptions) {
+ throw new Error("Expected the LSP client factory to receive a call");
+ }
+ if (!queryRequest) {
+ throw new Error("Expected the LSP client to receive a query");
+ }
+
+ return {
+ result,
+ clientFactoryOptions,
+ ensurePaths,
+ queryRequest,
+ hookPath,
+ usagePath,
+ };
+ } finally {
+ await manager.dispose();
+ }
+ }
+
+ test("prefers config-backed TypeScript roots for file-backed references queries", async () => {
+ const { result, clientFactoryOptions, ensurePaths, queryRequest, usagePath, hookPath } =
+ await queryTypeScriptFileBackedOperation({
+ operation: "references",
+ line: 1,
+ column: 1,
+ includeDeclaration: false,
+ buildRawResult: ({ usagePath }) => ({
+ operation: "references",
+ locations: [
+ {
+ uri: pathToFileURL(usagePath).href,
+ range: {
+ start: { line: 1, character: 21 },
+ end: { line: 1, character: 31 },
+ },
+ },
+ ],
+ }),
+ });
+
+ expect(clientFactoryOptions.rootPath).toBe(workspacePath);
+ expect(ensurePaths).toEqual([hookPath]);
+ expect(queryRequest).toMatchObject({
+ operation: "references",
+ line: 0,
+ character: 0,
+ includeDeclaration: false,
+ file: {
+ readablePath: hookPath,
+ },
+ });
+ expect(requireSingleRootQueryResult(result)).toMatchObject({
+ operation: "references",
+ serverId: "typescript",
+ rootUri: pathToFileURL(workspacePath).href,
+ locations: [
+ {
+ path: usagePath,
+ uri: pathToFileURL(usagePath).href,
+ },
+ ],
+ });
+ });
+
+ test("prefers config-backed TypeScript roots for file-backed document_symbols queries", async () => {
+ const { result, clientFactoryOptions, ensurePaths, queryRequest, hookPath } =
+ await queryTypeScriptFileBackedOperation({
+ operation: "document_symbols",
+ buildRawResult: ({ hookPath }) => ({
+ operation: "document_symbols",
+ symbols: [
+ {
+ name: "useAttempt",
+ kind: 12,
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 40 },
+ },
+ selectionRange: {
+ start: { line: 0, character: 16 },
+ end: { line: 0, character: 26 },
+ },
+ uri: pathToFileURL(hookPath).href,
+ },
+ ],
+ }),
+ });
+
+ expect(clientFactoryOptions.rootPath).toBe(workspacePath);
+ expect(ensurePaths).toEqual([hookPath]);
+ expect(queryRequest).toMatchObject({
+ operation: "document_symbols",
+ file: {
+ readablePath: hookPath,
+ },
+ });
+ expect(queryRequest.line).toBeUndefined();
+ expect(queryRequest.character).toBeUndefined();
+ expect(requireSingleRootQueryResult(result)).toMatchObject({
+ operation: "document_symbols",
+ serverId: "typescript",
+ rootUri: pathToFileURL(workspacePath).href,
+ symbols: [
+ {
+ name: "useAttempt",
+ path: hookPath,
+ uri: pathToFileURL(hookPath).href,
+ },
+ ],
+ });
+ });
+
+ test("reuses clients for the same workspace root and forwards normalized positions", async () => {
+ const ensureFile = mock(() => Promise.resolve(1));
+ let lastQueryRequest: Parameters[0] | undefined;
+ const query = mock((request: Parameters[0]) => {
+ lastQueryRequest = request;
+ return Promise.resolve({
+ operation: "hover" as const,
+ hover: "const value: 1",
+ });
+ });
+ const close = mock(() => Promise.resolve(undefined));
+ const client: LspClientInstance = {
+ isClosed: false,
+ ensureFile,
+ query,
+ close,
+ };
+ let clientFactoryOptions: CreateLspClientOptions | undefined;
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ clientFactoryOptions = options;
+ return Promise.resolve(client);
+ });
+
+ const manager = new LspManager({
+ registry: createRegistry(),
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ const firstResult = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "src/example.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "hover",
+ line: 2,
+ column: 3,
+ });
+ const secondResult = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "src/example.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "hover",
+ line: 2,
+ column: 3,
+ });
+
+ expect(requireSingleRootQueryResult(firstResult).hover).toBe("const value: 1");
+ expect(requireSingleRootQueryResult(secondResult).hover).toBe("const value: 1");
+ expect(clientFactory).toHaveBeenCalledTimes(1);
+ expect(ensureFile).toHaveBeenCalledTimes(2);
+ expect(clientFactoryOptions).toBeDefined();
+ if (!clientFactoryOptions) {
+ throw new Error("Expected the LSP client factory to receive a call");
+ }
+ expect(clientFactoryOptions.rootPath).toBe(workspacePath);
+ expect(clientFactoryOptions.launchPlan).toEqual({
+ command: "mux-test-fake-lsp",
+ args: ["--stdio"],
+ cwd: workspacePath,
+ env: undefined,
+ initializationOptions: undefined,
+ });
+
+ expect(lastQueryRequest).toBeDefined();
+ if (!lastQueryRequest) {
+ throw new Error("Expected the LSP client to receive a query");
+ }
+ expect(lastQueryRequest.line).toBe(1);
+ expect(lastQueryRequest.character).toBe(2);
+
+ await manager.dispose();
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ test("prefers config-backed TypeScript roots and warms explicit workspace_symbols file queries", async () => {
+ const resourcePath = path.join(
+ workspacePath,
+ "web",
+ "packages",
+ "teleport",
+ "src",
+ "services",
+ "resources",
+ "resource.ts"
+ );
+ await fs.mkdir(path.dirname(resourcePath), { recursive: true });
+ await fs.writeFile(
+ path.join(workspacePath, "tsconfig.json"),
+ JSON.stringify({ include: ["web/**/*.ts"] }, null, 2) + "\n"
+ );
+ await fs.writeFile(
+ path.join(workspacePath, "web", "packages", "teleport", "package.json"),
+ "{}\n"
+ );
+ await fs.writeFile(resourcePath, "export class ResourceService {}\n");
+
+ const ensureEvents: string[] = [];
+ const ensureFile = mock((file: Parameters[0]) => {
+ ensureEvents.push(`ensure:${file.readablePath}`);
+ return Promise.resolve(1);
+ });
+ const queryEvents: string[] = [];
+ let clientFactoryOptions: CreateLspClientOptions | undefined;
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ clientFactoryOptions = options;
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile,
+ query: mock((request: Parameters[0]) => {
+ queryEvents.push(`query:${request.file?.readablePath ?? "directory"}`);
+ return Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: [
+ {
+ name: "ResourceService",
+ kind: 5,
+ location: {
+ uri: pathToFileURL(resourcePath).href,
+ range: {
+ start: { line: 0, character: 13 },
+ end: { line: 0, character: 28 },
+ },
+ },
+ },
+ ],
+ });
+ }),
+ close: mock(() => Promise.resolve(undefined)),
+ });
+ });
+
+ const manager = new LspManager({
+ registry: createRegistry(),
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ try {
+ const result = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "web/packages/teleport/src/services/resources/resource.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "workspace_symbols",
+ query: "ResourceService",
+ });
+
+ expect(result).toMatchObject({
+ operation: "workspace_symbols",
+ serverId: "typescript",
+ rootUri: pathToFileURL(workspacePath).href,
+ symbols: [
+ {
+ name: "ResourceService",
+ kindLabel: "Class",
+ path: resourcePath,
+ uri: pathToFileURL(resourcePath).href,
+ exportInfo: {
+ isExported: true,
+ confidence: "heuristic",
+ evidence: "Found an export keyword near the declaration",
+ },
+ },
+ ],
+ });
+ expect(clientFactoryOptions).toBeDefined();
+ if (!clientFactoryOptions) {
+ throw new Error("Expected the LSP client factory to receive a call");
+ }
+ expect(clientFactoryOptions.rootPath).toBe(workspacePath);
+ expect(ensureEvents).toEqual([`ensure:${resourcePath}`]);
+ expect(queryEvents).toEqual([`query:${resourcePath}`]);
+ expect(ensureFile).toHaveBeenCalledTimes(1);
+ expect(clientFactory).toHaveBeenCalledTimes(1);
+ } finally {
+ await manager.dispose();
+ }
+ });
+
+ test("warms a representative TypeScript file before directory workspace_symbols queries", async () => {
+ const ensureFileCalls: Array[0]> = [];
+ const ensureFile = mock((file: Parameters[0]) => {
+ ensureFileCalls.push(file);
+ return Promise.resolve(1);
+ });
+ const queryRequests: Array[0]> = [];
+ const clientFactoryOptions: CreateLspClientOptions[] = [];
+ const close = mock(() => Promise.resolve(undefined));
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ clientFactoryOptions.push(options);
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile,
+ query: mock((request: Parameters[0]) => {
+ queryRequests.push(request);
+ return Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: [],
+ });
+ }),
+ close,
+ });
+ });
+
+ const manager = new LspManager({
+ registry: createRegistry(),
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ const result = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "./",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "workspace_symbols",
+ query: "ResourceService",
+ });
+
+ expect(result).toEqual({
+ operation: "workspace_symbols",
+ results: [],
+ });
+ expect(clientFactoryOptions.map((options) => options.rootPath)).toEqual([workspacePath]);
+ expect(ensureFileCalls).toEqual([
+ {
+ runtimePath: path.join(workspacePath, "src", "example.ts"),
+ readablePath: path.join(workspacePath, "src", "example.ts"),
+ uri: pathToFileURL(path.join(workspacePath, "src", "example.ts")).href,
+ languageId: "typescript",
+ },
+ ]);
+ expect(queryRequests).toHaveLength(1);
+ for (const request of queryRequests) {
+ expect(request).toMatchObject({
+ operation: "workspace_symbols",
+ query: "ResourceService",
+ });
+ expect(request.file).toBeUndefined();
+ }
+
+ await manager.dispose();
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ test("prefers repo-root TypeScript files over nested child TS projects during warm-up", async () => {
+ await fs.rm(path.join(workspacePath, "src"), { recursive: true, force: true });
+ await fs.rm(path.join(workspacePath, "packages"), { recursive: true, force: true });
+
+ const rootResourcePath = path.join(
+ workspacePath,
+ "web",
+ "packages",
+ "teleport",
+ "src",
+ "services",
+ "resources",
+ "resource.ts"
+ );
+ const designComponentPath = path.join(
+ workspacePath,
+ "web",
+ "packages",
+ "design",
+ "src",
+ "button.ts"
+ );
+ const e2eSpecPath = path.join(workspacePath, "e2e", "tests", "resource.spec.ts");
+ await fs.mkdir(path.dirname(rootResourcePath), { recursive: true });
+ await fs.mkdir(path.dirname(designComponentPath), { recursive: true });
+ await fs.mkdir(path.dirname(e2eSpecPath), { recursive: true });
+ await fs.writeFile(
+ path.join(workspacePath, "tsconfig.json"),
+ JSON.stringify({ include: ["web/**/*.ts"] }, null, 2) + "\n"
+ );
+ await fs.writeFile(path.join(workspacePath, "go.mod"), "module example.com/teleport\n");
+ await fs.writeFile(
+ path.join(workspacePath, "web", "packages", "design", "tsconfig.json"),
+ "{}\n"
+ );
+ await fs.writeFile(path.join(workspacePath, "e2e", "tsconfig.json"), "{}\n");
+ await fs.writeFile(rootResourcePath, "export class ResourceService {}\n");
+ await fs.writeFile(designComponentPath, "export const Button = 1;\n");
+ await fs.writeFile(e2eSpecPath, "export const resourceSpec = 1;\n");
+
+ const goDescriptor = createManualDescriptor("go", [".go"], "mux-test-go-lsp", [
+ "go.mod",
+ ".git",
+ ]);
+ const warmupPathsByRoot = new Map();
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile: mock((file: Parameters[0]) => {
+ const warmedPaths = warmupPathsByRoot.get(options.rootPath) ?? [];
+ warmedPaths.push(file.readablePath);
+ warmupPathsByRoot.set(options.rootPath, warmedPaths);
+ return Promise.resolve(1);
+ }),
+ query: mock(() => {
+ if (
+ options.descriptor.id === "typescript" &&
+ options.rootPath === workspacePath &&
+ warmupPathsByRoot.get(options.rootPath)?.at(-1) === rootResourcePath
+ ) {
+ return Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: [
+ {
+ name: "ResourceService",
+ kind: 5,
+ location: {
+ uri: pathToFileURL(rootResourcePath).href,
+ range: {
+ start: { line: 0, character: 13 },
+ end: { line: 0, character: 28 },
+ },
+ },
+ },
+ ],
+ });
+ }
+
+ return Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: [],
+ });
+ }),
+ close: mock(() => Promise.resolve(undefined)),
+ });
+ });
+
+ const manager = new LspManager({
+ registry: [...createRegistry(), goDescriptor],
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ try {
+ const result = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: ".",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "workspace_symbols",
+ query: "ResourceService",
+ });
+
+ expect(result).toMatchObject({
+ operation: "workspace_symbols",
+ results: [
+ {
+ serverId: "typescript",
+ rootUri: pathToFileURL(workspacePath).href,
+ symbols: [
+ {
+ name: "ResourceService",
+ path: rootResourcePath,
+ },
+ ],
+ },
+ ],
+ });
+ expect(warmupPathsByRoot).toEqual(
+ new Map([
+ [workspacePath, [rootResourcePath]],
+ [path.join(workspacePath, "e2e"), [e2eSpecPath]],
+ [path.join(workspacePath, "web", "packages", "design"), [designComponentPath]],
+ ])
+ );
+ } finally {
+ await manager.dispose();
+ }
+ });
+
+ test("prefers repo-root TypeScript files whose contents exactly match the queried symbol", async () => {
+ await fs.rm(path.join(workspacePath, "src"), { recursive: true, force: true });
+
+ const rootResourcePath = path.join(
+ workspacePath,
+ "web",
+ "packages",
+ "teleport",
+ "src",
+ "services",
+ "resources",
+ "resource.ts"
+ );
+ const misleadingPluralPath = path.join(
+ workspacePath,
+ "web",
+ "packages",
+ "teleterm",
+ "src",
+ "services",
+ "resources",
+ "resources-service.test.ts"
+ );
+ await fs.mkdir(path.dirname(rootResourcePath), { recursive: true });
+ await fs.mkdir(path.dirname(misleadingPluralPath), { recursive: true });
+ await fs.writeFile(
+ path.join(workspacePath, "tsconfig.json"),
+ JSON.stringify({ include: ["web/**/*.ts"] }, null, 2) + "\n"
+ );
+ await fs.writeFile(rootResourcePath, "export class ResourceService {}\n");
+ await fs.writeFile(misleadingPluralPath, "export class ResourcesService {}\n");
+
+ const warmupPathsByRoot = new Map();
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile: mock((file: Parameters[0]) => {
+ const warmedPaths = warmupPathsByRoot.get(options.rootPath) ?? [];
+ warmedPaths.push(file.readablePath);
+ warmupPathsByRoot.set(options.rootPath, warmedPaths);
+ return Promise.resolve(1);
+ }),
+ query: mock(() => {
+ const warmedPath = warmupPathsByRoot.get(options.rootPath)?.at(-1);
+ if (warmedPath === rootResourcePath) {
+ return Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: [
+ {
+ name: "ResourceService",
+ kind: 5,
+ location: {
+ uri: pathToFileURL(rootResourcePath).href,
+ range: {
+ start: { line: 0, character: 13 },
+ end: { line: 0, character: 28 },
+ },
+ },
+ },
+ ],
+ });
+ }
+
+ return Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: [
+ {
+ name: "ResourcesService",
+ kind: 5,
+ location: {
+ uri: pathToFileURL(misleadingPluralPath).href,
+ range: {
+ start: { line: 0, character: 13 },
+ end: { line: 0, character: 29 },
+ },
+ },
+ },
+ ],
+ });
+ }),
+ close: mock(() => Promise.resolve(undefined)),
+ });
+ });
+
+ const manager = new LspManager({
+ registry: createRegistry(),
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ try {
+ const result = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: ".",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "workspace_symbols",
+ query: "ResourceService",
+ });
+
+ expect(result).toMatchObject({
+ operation: "workspace_symbols",
+ results: [
+ {
+ serverId: "typescript",
+ rootUri: pathToFileURL(workspacePath).href,
+ symbols: [
+ {
+ name: "ResourceService",
+ path: rootResourcePath,
+ },
+ ],
+ },
+ ],
+ });
+ expect(warmupPathsByRoot).toEqual(new Map([[workspacePath, [rootResourcePath]]]));
+ } finally {
+ await manager.dispose();
+ }
+ });
+
+ test("filters Go workspace_symbols to exact matches by default for single-root queries", async () => {
+ const result = await queryGoWorkspaceSymbols({
+ filePath: "main.go",
+ });
+
+ expect(requireSingleRootQueryResult(result)).toMatchObject({
+ operation: "workspace_symbols",
+ serverId: "go",
+ rootUri: pathToFileURL(workspacePath).href,
+ symbols: [
+ {
+ name: "useAttempt",
+ path: path.join(workspacePath, "main.go"),
+ },
+ ],
+ });
+ });
+
+ test("filters Go workspace_symbols for directory queries unless the env var disables it", async () => {
+ const defaultResults = requireDirectoryWorkspaceSymbolsResults(
+ await queryGoWorkspaceSymbols({
+ filePath: ".",
+ })
+ );
+ expect(defaultResults).toHaveLength(1);
+ expect(defaultResults[0]).toMatchObject({
+ serverId: "go",
+ rootUri: pathToFileURL(workspacePath).href,
+ symbols: [
+ {
+ name: "useAttempt",
+ },
+ ],
+ });
+
+ const disabledResults = requireDirectoryWorkspaceSymbolsResults(
+ await queryGoWorkspaceSymbols({
+ filePath: ".",
+ envValue: "false",
+ })
+ );
+ expect(disabledResults).toHaveLength(1);
+ expect(disabledResults[0]).toMatchObject({
+ serverId: "go",
+ rootUri: pathToFileURL(workspacePath).href,
+ symbols: [{ name: "useAttemptHelper" }, { name: "useAttempt" }, { name: "attemptUse" }],
+ });
+ });
+
+ test("suppresses fuzzy Go workspace_symbols groups when another root has an exact match", async () => {
+ const defaultResults = requireDirectoryWorkspaceSymbolsResults(
+ await queryMixedTypeScriptAndGoWorkspaceSymbols({
+ query: "useAttempt",
+ goSymbols: ["useAttemptHelper", "attemptUse"],
+ })
+ );
+ expect(defaultResults).toHaveLength(1);
+ expect(defaultResults[0]).toMatchObject({
+ serverId: "typescript",
+ rootUri: pathToFileURL(path.join(workspacePath, "web", "packages", "teleport")).href,
+ symbols: [
+ {
+ name: "useAttempt",
+ path: path.join(workspacePath, "web", "packages", "teleport", "src", "useAttempt.ts"),
+ },
+ ],
+ });
+
+ const disabledResults = requireDirectoryWorkspaceSymbolsResults(
+ await queryMixedTypeScriptAndGoWorkspaceSymbols({
+ query: "useAttempt",
+ goSymbols: ["useAttemptHelper", "attemptUse"],
+ envValue: "false",
+ })
+ );
+ expect(disabledResults).toHaveLength(2);
+ const disabledGoResult = disabledResults.find((result) => result.serverId === "go");
+ if (!disabledGoResult) {
+ throw new Error("Expected Go workspace_symbols results when the experiment is disabled");
+ }
+ expect(disabledGoResult).toMatchObject({
+ rootUri: pathToFileURL(workspacePath).href,
+ symbols: [{ name: "useAttemptHelper" }, { name: "attemptUse" }],
+ });
+ const disabledTypeScriptResult = disabledResults.find(
+ (result) => result.serverId === "typescript"
+ );
+ if (!disabledTypeScriptResult) {
+ throw new Error("Expected TypeScript workspace_symbols results for the nested root");
+ }
+ expect(disabledTypeScriptResult).toMatchObject({
+ rootUri: pathToFileURL(path.join(workspacePath, "web", "packages", "teleport")).href,
+ symbols: [{ name: "useAttempt" }],
+ });
+ });
+
+ test("keeps exact Go workspace_symbols groups when Go also has an exact match", async () => {
+ const results = requireDirectoryWorkspaceSymbolsResults(
+ await queryMixedTypeScriptAndGoWorkspaceSymbols({
+ query: "ResourceService",
+ goSymbols: ["ResourceService", "ResourceServiceHelper"],
+ })
+ );
+
+ expect(results).toHaveLength(2);
+ const goResult = results.find((result) => result.serverId === "go");
+ if (!goResult) {
+ throw new Error("Expected Go workspace_symbols results when Go has an exact match");
+ }
+ expect(goResult).toMatchObject({
+ rootUri: pathToFileURL(workspacePath).href,
+ symbols: [{ name: "ResourceService" }],
+ });
+ const typeScriptResult = results.find((result) => result.serverId === "typescript");
+ if (!typeScriptResult) {
+ throw new Error("Expected TypeScript workspace_symbols results for the nested root");
+ }
+ expect(typeScriptResult).toMatchObject({
+ rootUri: pathToFileURL(path.join(workspacePath, "web", "packages", "teleport")).href,
+ symbols: [
+ {
+ name: "ResourceService",
+ path: path.join(
+ workspacePath,
+ "web",
+ "packages",
+ "teleport",
+ "src",
+ "ResourceService.ts"
+ ),
+ },
+ ],
+ });
+ });
+
+ test("prefers the deepest matching workspace_symbols root for nested directories", async () => {
+ const pythonWorkspacePath = path.join(workspacePath, "services", "python");
+ await fs.mkdir(pythonWorkspacePath, { recursive: true });
+ await fs.writeFile(
+ path.join(pythonWorkspacePath, "pyproject.toml"),
+ "[project]\nname = 'python-service'\n"
+ );
+
+ const pythonDescriptor: LspServerDescriptor = {
+ id: "python",
+ extensions: [".py"],
+ launch: {
+ type: "manual",
+ command: "mux-test-python-lsp",
+ args: ["--stdio"],
+ },
+ rootMarkers: ["pyproject.toml", ".git"],
+ languageIdForPath: () => "python",
+ };
+ let clientFactoryOptions: CreateLspClientOptions | undefined;
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ clientFactoryOptions = options;
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile: mock(() => Promise.resolve(1)),
+ query: mock(() =>
+ Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: [],
+ })
+ ),
+ close: mock(() => Promise.resolve(undefined)),
+ });
+ });
+
+ const manager = new LspManager({
+ registry: [...createRegistry(), pythonDescriptor],
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ try {
+ const result = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "services/python",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "workspace_symbols",
+ query: "ResourceService",
+ });
+
+ expect(result).toMatchObject({
+ operation: "workspace_symbols",
+ results: [],
+ });
+ expect(clientFactory).toHaveBeenCalledTimes(1);
+ expect(clientFactoryOptions).toBeDefined();
+ if (!clientFactoryOptions) {
+ throw new Error("Expected the LSP client factory to receive a call");
+ }
+ expect(clientFactoryOptions.rootPath).toBe(pythonWorkspacePath);
+ } finally {
+ await manager.dispose();
+ }
+ });
+
+ test("returns repo-root TypeScript symbols in mixed-language monorepos", async () => {
+ await fs.writeFile(path.join(workspacePath, "go.mod"), "module example.com/mux\n");
+ await fs.writeFile(path.join(workspacePath, "Cargo.toml"), "[package]\nname = 'mux'\n");
+ await fs.writeFile(path.join(workspacePath, "packages", "pkg", "tsconfig.json"), "{}\n");
+ await fs.writeFile(
+ path.join(workspacePath, "packages", "pkg", "src", "nested.ts"),
+ "export class ResourceService {}\n"
+ );
+ await fs.mkdir(path.join(workspacePath, "services", "api"), { recursive: true });
+ await fs.mkdir(path.join(workspacePath, "crates", "core", "src"), { recursive: true });
+ await fs.writeFile(path.join(workspacePath, "services", "api", "main.go"), "package api\n");
+ await fs.writeFile(
+ path.join(workspacePath, "crates", "core", "src", "lib.rs"),
+ "pub fn core() {}\n"
+ );
+
+ const goDescriptor = createManualDescriptor("go", [".go"], "mux-test-go-lsp", [
+ "go.mod",
+ ".git",
+ ]);
+ const rustDescriptor = createManualDescriptor("rust", [".rs"], "mux-test-rust-lsp", [
+ "Cargo.toml",
+ ".git",
+ ]);
+ const queryOrder: string[] = [];
+ const warmupPathsByRoot = new Map();
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile: mock((file: Parameters[0]) => {
+ const warmedPaths = warmupPathsByRoot.get(options.rootPath) ?? [];
+ warmedPaths.push(file.readablePath);
+ warmupPathsByRoot.set(options.rootPath, warmedPaths);
+ return Promise.resolve(1);
+ }),
+ query: mock(() => {
+ queryOrder.push(`${options.descriptor.id}:${options.rootPath}`);
+ if (options.rootPath === path.join(workspacePath, "packages", "pkg")) {
+ return Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: [
+ {
+ name: "ResourceService",
+ kind: 5,
+ location: {
+ uri: pathToFileURL(
+ path.join(workspacePath, "packages", "pkg", "src", "nested.ts")
+ ).href,
+ range: {
+ start: { line: 0, character: 13 },
+ end: { line: 0, character: 28 },
+ },
+ },
+ },
+ ],
+ });
+ }
+
+ return Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: [],
+ });
+ }),
+ close: mock(() => Promise.resolve(undefined)),
+ });
+ });
+
+ const manager = new LspManager({
+ registry: [...createRegistry(), goDescriptor, rustDescriptor],
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ try {
+ const result = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: ".",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "workspace_symbols",
+ query: "ResourceService",
+ });
+
+ expect(result).toMatchObject({
+ operation: "workspace_symbols",
+ results: [
+ {
+ serverId: "typescript",
+ rootUri: pathToFileURL(path.join(workspacePath, "packages", "pkg")).href,
+ symbols: [
+ {
+ name: "ResourceService",
+ path: path.join(workspacePath, "packages", "pkg", "src", "nested.ts"),
+ },
+ ],
+ },
+ ],
+ });
+ expect(warmupPathsByRoot).toEqual(
+ new Map([
+ [workspacePath, [path.join(workspacePath, "src", "example.ts")]],
+ [
+ path.join(workspacePath, "packages", "pkg"),
+ [path.join(workspacePath, "packages", "pkg", "src", "nested.ts")],
+ ],
+ ])
+ );
+ expect(queryOrder).toEqual([
+ `typescript:${workspacePath}`,
+ `go:${workspacePath}`,
+ `rust:${workspacePath}`,
+ `typescript:${path.join(workspacePath, "packages", "pkg")}`,
+ ]);
+ expect(clientFactory).toHaveBeenCalledTimes(4);
+ } finally {
+ await manager.dispose();
+ }
+ });
+
+ test("ignores package-only TypeScript descendants during workspace_symbols root discovery", async () => {
+ await fs.writeFile(path.join(workspacePath, "pyproject.toml"), "[project]\nname = 'mixed'\n");
+ await fs.mkdir(path.join(workspacePath, "packages", "other", "src"), { recursive: true });
+ await fs.mkdir(path.join(workspacePath, "packages", "project", "src"), { recursive: true });
+ await fs.writeFile(path.join(workspacePath, "packages", "other", "package.json"), "{}\n");
+ await fs.writeFile(
+ path.join(workspacePath, "packages", "other", "src", "other.ts"),
+ "export const other = 1;\n"
+ );
+ await fs.writeFile(path.join(workspacePath, "packages", "project", "package.json"), "{}\n");
+ await fs.writeFile(path.join(workspacePath, "packages", "project", "tsconfig.json"), "{}\n");
+ await fs.writeFile(
+ path.join(workspacePath, "packages", "project", "src", "project.ts"),
+ "export class ProjectResource {}\n"
+ );
+ await fs.writeFile(
+ path.join(workspacePath, "resource.py"),
+ "class ResourceService:\n pass\n"
+ );
+
+ const pythonDescriptor = createManualDescriptor("python", [".py"], "mux-test-python-lsp", [
+ "pyproject.toml",
+ ".git",
+ ]);
+ const queryOrder: string[] = [];
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile: mock(() => Promise.resolve(1)),
+ query: mock(() => {
+ queryOrder.push(`${options.descriptor.id}:${options.rootPath}`);
+ if (options.descriptor.id === "typescript") {
+ throw new Error("No Project");
+ }
+
+ return Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: [
+ {
+ name: "ResourceService",
+ kind: 5,
+ containerName: "resource",
+ location: {
+ uri: pathToFileURL(path.join(workspacePath, "resource.py")).href,
+ range: {
+ start: { line: 0, character: 6 },
+ end: { line: 0, character: 21 },
+ },
+ },
+ },
+ ],
+ });
+ }),
+ close: mock(() => Promise.resolve(undefined)),
+ });
+ });
+
+ const manager = new LspManager({
+ registry: [...createRegistry(), pythonDescriptor],
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ try {
+ const result = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: ".",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "workspace_symbols",
+ query: "ResourceService",
+ });
+
+ expect(result).toMatchObject({
+ operation: "workspace_symbols",
+ results: [
+ {
+ serverId: "python",
+ rootUri: pathToFileURL(workspacePath).href,
+ symbols: [
+ {
+ name: "ResourceService",
+ path: path.join(workspacePath, "resource.py"),
+ containerName: "resource",
+ },
+ ],
+ },
+ ],
+ });
+ expect(queryOrder).toEqual([
+ `typescript:${workspacePath}`,
+ `python:${workspacePath}`,
+ `typescript:${path.join(workspacePath, "packages", "project")}`,
+ ]);
+ expect(result.warning).toBeUndefined();
+ } finally {
+ await manager.dispose();
+ }
+ });
+
+ test("summarizes workspace_symbols warnings when many descendant roots fail", async () => {
+ await fs.writeFile(path.join(workspacePath, "pyproject.toml"), "[project]\nname = 'mixed'\n");
+ for (const projectName of ["proj-a", "proj-b", "proj-c", "proj-d"]) {
+ await fs.mkdir(path.join(workspacePath, "packages", projectName, "src"), { recursive: true });
+ await fs.writeFile(path.join(workspacePath, "packages", projectName, "package.json"), "{}\n");
+ await fs.writeFile(
+ path.join(workspacePath, "packages", projectName, "tsconfig.json"),
+ "{}\n"
+ );
+ await fs.writeFile(
+ path.join(workspacePath, "packages", projectName, "src", `${projectName}.ts`),
+ `export const ${projectName.replace(/-/g, "_")} = 1;\n`
+ );
+ }
+ await fs.writeFile(
+ path.join(workspacePath, "resource.py"),
+ "class ResourceService:\n pass\n"
+ );
+
+ const pythonDescriptor = createManualDescriptor("python", [".py"], "mux-test-python-lsp", [
+ "pyproject.toml",
+ ".git",
+ ]);
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile: mock(() => Promise.resolve(1)),
+ query: mock(() => {
+ if (options.descriptor.id === "typescript") {
+ throw new Error("No Project");
+ }
+
+ return Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: [
+ {
+ name: "ResourceService",
+ kind: 5,
+ location: {
+ uri: pathToFileURL(path.join(workspacePath, "resource.py")).href,
+ range: {
+ start: { line: 0, character: 6 },
+ end: { line: 0, character: 21 },
+ },
+ },
+ },
+ ],
+ });
+ }),
+ close: mock(() => Promise.resolve(undefined)),
+ });
+ });
+
+ const manager = new LspManager({
+ registry: [...createRegistry(), pythonDescriptor],
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ try {
+ const result = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: ".",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "workspace_symbols",
+ query: "ResourceService",
+ });
+
+ expect(result.warning).toBeUndefined();
+ const workspaceSymbolsResults = requireDirectoryWorkspaceSymbolsResults(result);
+ expect(workspaceSymbolsResults).toHaveLength(1);
+ expect(workspaceSymbolsResults[0]).toMatchObject({
+ serverId: "python",
+ rootUri: pathToFileURL(workspacePath).href,
+ });
+ expect(workspaceSymbolsResults[0]?.symbols).toHaveLength(1);
+ expect(workspaceSymbolsResults[0]?.symbols[0]).toMatchObject({
+ name: "ResourceService",
+ path: path.join(workspacePath, "resource.py"),
+ });
+ if (!("results" in result)) {
+ throw new Error("Expected directory workspace_symbols metadata");
+ }
+ expect(result.skippedRoots).toHaveLength(5);
+ expect(result.skippedRoots?.every((root) => root.reasonCode === "query_failed")).toBe(true);
+ } finally {
+ await manager.dispose();
+ }
+ });
+
+ test("surfaces structured skippedRoots guidance alongside partial workspace_symbols success", async () => {
+ await fs.writeFile(path.join(workspacePath, "pyproject.toml"), "[project]\nname = 'mixed'\n");
+ await fs.writeFile(path.join(workspacePath, "Cargo.toml"), "[package]\nname = 'mux'\n");
+ await fs.writeFile(
+ path.join(workspacePath, "resource.py"),
+ "class ResourceService:\n pass\n"
+ );
+
+ const pythonDescriptor = createManualDescriptor("python", [".py"], "mux-test-python-lsp", [
+ "pyproject.toml",
+ ".git",
+ ]);
+ const rustDescriptor: LspServerDescriptor = {
+ id: "rust",
+ extensions: [".rs"],
+ launch: {
+ type: "provisioned",
+ strategies: [
+ {
+ type: "unsupported",
+ message:
+ "rust-analyzer is not available on PATH and automatic installation is not supported yet",
+ },
+ ],
+ },
+ rootMarkers: ["Cargo.toml", ".git"],
+ languageIdForPath: () => "rust",
+ };
+ const resourcePath = path.join(workspacePath, "resource.py");
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ if (options.descriptor.id !== "python") {
+ throw new Error("Expected unsupported rust provisioning to fail before client creation");
+ }
+
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile: mock(() => Promise.resolve(1)),
+ query: mock(() =>
+ Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: [
+ {
+ name: "ResourceService",
+ kind: 5,
+ location: {
+ uri: pathToFileURL(resourcePath).href,
+ range: {
+ start: { line: 0, character: 6 },
+ end: { line: 0, character: 21 },
+ },
+ },
+ },
+ ],
+ })
+ ),
+ close: mock(() => Promise.resolve(undefined)),
+ });
+ });
+
+ const manager = new LspManager({
+ registry: [pythonDescriptor, rustDescriptor],
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ try {
+ const result = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: ".",
+ policyContext: {
+ provisioningMode: "auto",
+ trustedWorkspaceExecution: true,
+ },
+ operation: "workspace_symbols",
+ query: "ResourceService",
+ });
+
+ expect(result).toMatchObject({
+ operation: "workspace_symbols",
+ results: [
+ {
+ serverId: "python",
+ rootUri: pathToFileURL(workspacePath).href,
+ symbols: [{ name: "ResourceService", path: resourcePath }],
+ },
+ ],
+ skippedRoots: [
+ {
+ serverId: "rust",
+ rootUri: pathToFileURL(workspacePath).href,
+ reasonCode: "unsupported_provisioning",
+ installGuidance:
+ "Install rust-analyzer and ensure it is available on PATH, or query a representative source file for a supported language.",
+ },
+ ],
+ });
+ expect(clientFactory).toHaveBeenCalledTimes(1);
+ } finally {
+ await manager.dispose();
+ }
+ });
+
+ test("adds a disambiguation hint when multiple roots return workspace symbol matches", async () => {
+ await fs.writeFile(path.join(workspacePath, "pyproject.toml"), "[project]\nname = 'mixed'\n");
+ await fs.writeFile(
+ path.join(workspacePath, "src", "resource.ts"),
+ "export class ResourceService {}\n"
+ );
+ await fs.writeFile(
+ path.join(workspacePath, "resource.py"),
+ "class ResourceService:\n pass\n"
+ );
+
+ const pythonDescriptor = createManualDescriptor("python", [".py"], "mux-test-python-lsp", [
+ "pyproject.toml",
+ ".git",
+ ]);
+ const typescriptResourcePath = path.join(workspacePath, "src", "resource.ts");
+ const pythonResourcePath = path.join(workspacePath, "resource.py");
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile: mock(() => Promise.resolve(1)),
+ query: mock(() =>
+ Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: [
+ {
+ name: "ResourceService",
+ kind: 5,
+ location: {
+ uri:
+ options.descriptor.id === "typescript"
+ ? pathToFileURL(typescriptResourcePath).href
+ : pathToFileURL(pythonResourcePath).href,
+ range: {
+ start: { line: 0, character: 6 },
+ end: { line: 0, character: 21 },
+ },
+ },
+ },
+ ],
+ })
+ ),
+ close: mock(() => Promise.resolve(undefined)),
+ });
+ });
+
+ const manager = new LspManager({
+ registry: [...createRegistry(), pythonDescriptor],
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ try {
+ const result = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: ".",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "workspace_symbols",
+ query: "ResourceService",
+ });
+
+ if (!("results" in result)) {
+ throw new Error("Expected a directory workspace_symbols result");
+ }
+ expect(result.results).toHaveLength(2);
+ expect(result.disambiguationHint).toContain("ResourceService");
+ expect(result.disambiguationHint).toContain("kindLabel");
+ } finally {
+ await manager.dispose();
+ }
+ });
+
+ test("returns an empty workspace_symbols result when every queried root succeeds without symbols", async () => {
+ await fs.writeFile(path.join(workspacePath, "pyproject.toml"), "[project]\nname = 'mixed'\n");
+
+ const pythonDescriptor = createManualDescriptor("python", [".py"], "mux-test-python-lsp", [
+ "pyproject.toml",
+ ".git",
+ ]);
+ const clientFactory = mock((_options: CreateLspClientOptions): Promise => {
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile: mock(() => Promise.resolve(1)),
+ query: mock(() =>
+ Promise.resolve({
+ operation: "workspace_symbols" as const,
+ symbols: [],
+ })
+ ),
+ close: mock(() => Promise.resolve(undefined)),
+ });
+ });
+
+ const manager = new LspManager({
+ registry: [...createRegistry(), pythonDescriptor],
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ try {
+ const result = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: ".",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "workspace_symbols",
+ query: "ResourceService",
+ });
+
+ expect(result).toEqual({
+ operation: "workspace_symbols",
+ results: [],
+ });
+ expect(clientFactory).toHaveBeenCalledTimes(2);
+ } finally {
+ await manager.dispose();
+ }
+ });
+
+ test("fails workspace_symbols directory inference when every queried root errors", async () => {
+ await fs.writeFile(path.join(workspacePath, "pyproject.toml"), "[project]\nname = 'mixed'\n");
+
+ const pythonDescriptor = createManualDescriptor("python", [".py"], "mux-test-python-lsp", [
+ "pyproject.toml",
+ ".git",
+ ]);
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile: mock(() => Promise.resolve(1)),
+ query: mock(() => {
+ if (options.descriptor.id === "typescript") {
+ throw new Error("tsserver unavailable");
+ }
+
+ throw new Error("No Project");
+ }),
+ close: mock(() => Promise.resolve(undefined)),
+ });
+ });
+
+ const manager = new LspManager({
+ registry: [...createRegistry(), pythonDescriptor],
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ try {
+ // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime
+ await expect(
+ manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: ".",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "workspace_symbols",
+ query: "ResourceService",
+ })
+ ).rejects.toThrow(
+ "No usable LSP roots are available for directory .; typescript (package.json) at .: tsserver unavailable; python (pyproject.toml) at .: No Project. Install the missing language server or query a representative source file for a supported language."
+ );
+ expect(clientFactory).toHaveBeenCalledTimes(2);
+ } finally {
+ await manager.dispose();
+ }
+ });
+
+ test("surfaces unsupported PATH-only directory roots when no usable LSP roots remain", async () => {
+ await fs.writeFile(path.join(workspacePath, "Cargo.toml"), "[package]\nname = 'mux'\n");
+
+ const rustDescriptor: LspServerDescriptor = {
+ id: "rust",
+ extensions: [".rs"],
+ launch: {
+ type: "provisioned",
+ strategies: [
+ {
+ type: "unsupported",
+ message:
+ "rust-analyzer is not available on PATH and automatic installation is not supported yet",
+ },
+ ],
+ },
+ rootMarkers: ["Cargo.toml", ".git"],
+ languageIdForPath: () => "rust",
+ };
+ const clientFactory = mock((_options: CreateLspClientOptions): Promise => {
+ throw new Error("Expected unsupported provisioning to fail before client creation");
+ });
+
+ const manager = new LspManager({
+ registry: [rustDescriptor],
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ try {
+ // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime
+ await expect(
+ manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: ".",
+ policyContext: {
+ provisioningMode: "auto",
+ trustedWorkspaceExecution: true,
+ },
+ operation: "workspace_symbols",
+ query: "ResourceService",
+ })
+ ).rejects.toThrow(
+ "rust-analyzer is not available on PATH and automatic installation is not supported yet"
+ );
+ // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime
+ await expect(
+ manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: ".",
+ policyContext: {
+ provisioningMode: "auto",
+ trustedWorkspaceExecution: true,
+ },
+ operation: "workspace_symbols",
+ query: "ResourceService",
+ })
+ ).rejects.toThrow("Install rust-analyzer and ensure it is available on PATH");
+ expect(clientFactory).toHaveBeenCalledTimes(0);
+ } finally {
+ await manager.dispose();
+ }
+ });
+
+ test("reuses launch-plan probes for warm clients but re-runs them for a different root", async () => {
+ const lspBinDir = path.join(workspacePath, "tools", "bin");
+ const lspExecutable = path.join(lspBinDir, "fake-lsp");
+ const launchPath = prependToPath(lspBinDir);
+ await fs.mkdir(lspBinDir, { recursive: true });
+ await fs.writeFile(lspExecutable, "#!/bin/sh\nexit 0\n");
+ await fs.chmod(lspExecutable, 0o755);
+
+ const baseDescriptor = createRegistry()[0];
+ if (!baseDescriptor) {
+ throw new Error("Expected the test registry to provide a descriptor");
+ }
+
+ const descriptor: LspServerDescriptor = {
+ ...baseDescriptor,
+ launch: {
+ type: "manual",
+ command: "fake-lsp",
+ args: ["--stdio"],
+ env: { PATH: launchPath },
+ },
+ };
+ const launchPlans: Array = [];
+ const closeMocks: Array> = [];
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ launchPlans.push(options.launchPlan);
+ const close = mock(() => Promise.resolve(undefined));
+ closeMocks.push(close);
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile: mock(() => Promise.resolve(1)),
+ query: mock(() =>
+ Promise.resolve({ operation: "hover" as const, hover: options.rootPath })
+ ),
+ close,
+ });
+ });
+
+ const manager = new LspManager({
+ registry: [descriptor],
+ clientFactory,
+ });
+ const runtime = new CountingLocalRuntime(workspacePath);
+
+ const first = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "src/example.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "hover",
+ line: 1,
+ column: 1,
+ });
+ const second = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "src/example.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "hover",
+ line: 1,
+ column: 1,
+ });
+ const third = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "packages/pkg/src/nested.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "hover",
+ line: 1,
+ column: 1,
+ });
+
+ expect(requireSingleRootQueryResult(first).hover).toBe(workspacePath);
+ expect(requireSingleRootQueryResult(second).hover).toBe(workspacePath);
+ expect(requireSingleRootQueryResult(third).hover).toBe(
+ path.join(workspacePath, "packages", "pkg")
+ );
+ expect(clientFactory).toHaveBeenCalledTimes(2);
+ expect(runtime.pathProbeCommands).toHaveLength(2);
+ expect(launchPlans).toEqual([
+ {
+ command: lspExecutable,
+ args: ["--stdio"],
+ cwd: workspacePath,
+ env: { PATH: launchPath },
+ initializationOptions: undefined,
+ },
+ {
+ command: lspExecutable,
+ args: ["--stdio"],
+ cwd: path.join(workspacePath, "packages", "pkg"),
+ env: { PATH: launchPath },
+ initializationOptions: undefined,
+ },
+ ]);
+
+ await manager.dispose();
+ expect(closeMocks).toHaveLength(2);
+ for (const close of closeMocks) {
+ expect(close).toHaveBeenCalledTimes(1);
+ }
+ });
+
+ test("creates separate clients when LSP policy context changes for the same root", async () => {
+ const externalBinDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-lsp-global-bin-"));
+ try {
+ const localExecutable = path.join(
+ workspacePath,
+ "node_modules",
+ ".bin",
+ "typescript-language-server"
+ );
+ const workspacePathExecutable = path.join(
+ workspacePath,
+ "tools",
+ "bin",
+ "typescript-language-server"
+ );
+ const externalPathExecutable = path.join(externalBinDir, "typescript-language-server");
+ await fs.mkdir(path.dirname(localExecutable), { recursive: true });
+ await fs.mkdir(path.dirname(workspacePathExecutable), { recursive: true });
+ await fs.writeFile(localExecutable, "#!/bin/sh\nexit 0\n");
+ await fs.writeFile(workspacePathExecutable, "#!/bin/sh\nexit 0\n");
+ await fs.writeFile(externalPathExecutable, "#!/bin/sh\nexit 0\n");
+ await fs.chmod(localExecutable, 0o755);
+ await fs.chmod(workspacePathExecutable, 0o755);
+ await fs.chmod(externalPathExecutable, 0o755);
+
+ const descriptor: LspServerDescriptor = {
+ id: "typescript",
+ extensions: [".ts"],
+ launch: {
+ type: "provisioned",
+ args: ["--stdio"],
+ env: {
+ PATH: [path.join(workspacePath, "tools", "bin"), prependToPath(externalBinDir)]
+ .filter((value) => value.length > 0)
+ .join(path.delimiter),
+ },
+ strategies: [
+ {
+ type: "workspaceLocalExecutable",
+ relativeCandidates: ["node_modules/.bin/typescript-language-server"],
+ },
+ { type: "pathCommand", command: "typescript-language-server" },
+ ],
+ },
+ rootMarkers: ["package.json", ".git"],
+ languageIdForPath: () => "typescript",
+ };
+
+ const launchPlans: Array = [];
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ launchPlans.push(options.launchPlan);
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile: mock(() => Promise.resolve(1)),
+ query: mock(() =>
+ Promise.resolve({ operation: "hover" as const, hover: options.launchPlan.command })
+ ),
+ close: mock(() => Promise.resolve(undefined)),
+ });
+ });
+
+ const manager = new LspManager({ registry: [descriptor], clientFactory });
+ const runtime = new CountingLocalRuntime(workspacePath);
+ try {
+ const trustedResult = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "src/example.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "hover",
+ line: 1,
+ column: 1,
+ });
+ const untrustedResult = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "src/example.ts",
+ policyContext: {
+ provisioningMode: "manual",
+ trustedWorkspaceExecution: false,
+ },
+ operation: "hover",
+ line: 1,
+ column: 1,
+ });
+
+ expect(requireSingleRootQueryResult(trustedResult).hover).toBe(localExecutable);
+ expect(requireSingleRootQueryResult(untrustedResult).hover).toBe(externalPathExecutable);
+ expect(clientFactory).toHaveBeenCalledTimes(2);
+ expect(launchPlans.map((plan) => plan.command)).toEqual([
+ localExecutable,
+ externalPathExecutable,
+ ]);
+ } finally {
+ await manager.dispose();
+ }
+ } finally {
+ await fs.rm(externalBinDir, { recursive: true, force: true });
+ }
+ });
+
+ test("re-probes launch plans after a closed client is recreated", async () => {
+ const firstBinDir = path.join(workspacePath, "tools", "first-bin");
+ const secondBinDir = path.join(workspacePath, "tools", "second-bin");
+ const firstExecutable = path.join(firstBinDir, "fake-lsp");
+ const secondExecutable = path.join(secondBinDir, "fake-lsp");
+ await fs.mkdir(firstBinDir, { recursive: true });
+ await fs.mkdir(secondBinDir, { recursive: true });
+ await fs.writeFile(firstExecutable, "#!/bin/sh\nexit 0\n");
+ await fs.writeFile(secondExecutable, "#!/bin/sh\nexit 0\n");
+ await fs.chmod(firstExecutable, 0o755);
+ await fs.chmod(secondExecutable, 0o755);
+
+ const baseDescriptor = createRegistry()[0];
+ if (!baseDescriptor) {
+ throw new Error("Expected the test registry to provide a descriptor");
+ }
+
+ const descriptor: LspServerDescriptor = {
+ ...baseDescriptor,
+ launch: {
+ type: "manual",
+ command: "fake-lsp",
+ args: ["--stdio"],
+ env: { PATH: prependToPath(firstBinDir) },
+ },
+ };
+
+ const launchPlans: Array = [];
+ const closedStates: boolean[] = [];
+ const closeMocks: Array> = [];
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ launchPlans.push(options.launchPlan);
+ const clientIndex = closedStates.length;
+ closedStates.push(false);
+ const close = mock(() => {
+ closedStates[clientIndex] = true;
+ return Promise.resolve(undefined);
+ });
+ closeMocks.push(close);
+ return Promise.resolve({
+ get isClosed() {
+ return closedStates[clientIndex] ?? false;
+ },
+ ensureFile: mock(() => Promise.resolve(1)),
+ query: mock(() =>
+ Promise.resolve({ operation: "hover" as const, hover: options.launchPlan.command })
+ ),
+ close,
+ });
+ });
+
+ const manager = new LspManager({
+ registry: [descriptor],
+ clientFactory,
+ });
+ const runtime = new CountingLocalRuntime(workspacePath);
+
+ const first = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "src/example.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "hover",
+ line: 1,
+ column: 1,
+ });
+ const warmReuse = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "src/example.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "hover",
+ line: 1,
+ column: 1,
+ });
+
+ await closeMocks[0]?.();
+ descriptor.launch = {
+ ...descriptor.launch,
+ env: { PATH: prependToPath(secondBinDir) },
+ };
+
+ const recreated = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "src/example.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "hover",
+ line: 1,
+ column: 1,
+ });
+ const recreatedWarmReuse = await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "src/example.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "hover",
+ line: 1,
+ column: 1,
+ });
+
+ expect(requireSingleRootQueryResult(first).hover).toBe(firstExecutable);
+ expect(requireSingleRootQueryResult(warmReuse).hover).toBe(firstExecutable);
+ expect(requireSingleRootQueryResult(recreated).hover).toBe(secondExecutable);
+ expect(requireSingleRootQueryResult(recreatedWarmReuse).hover).toBe(secondExecutable);
+ expect(clientFactory).toHaveBeenCalledTimes(2);
+ expect(runtime.pathProbeCommands).toHaveLength(2);
+ expect(launchPlans).toEqual([
+ {
+ command: firstExecutable,
+ args: ["--stdio"],
+ cwd: workspacePath,
+ env: { PATH: prependToPath(firstBinDir) },
+ initializationOptions: undefined,
+ },
+ {
+ command: secondExecutable,
+ args: ["--stdio"],
+ cwd: workspacePath,
+ env: { PATH: prependToPath(secondBinDir) },
+ initializationOptions: undefined,
+ },
+ ]);
+
+ await manager.dispose();
+ expect(closeMocks).toHaveLength(2);
+ expect(closeMocks[0]).toHaveBeenCalledTimes(1);
+ expect(closeMocks[1]).toHaveBeenCalledTimes(1);
+ });
+
+ test("deduplicates concurrent client creation for the same workspace root", async () => {
+ const ensureFile = mock(() => Promise.resolve(1));
+ const query = mock(() =>
+ Promise.resolve({
+ operation: "hover" as const,
+ hover: "const value: 1",
+ })
+ );
+ const close = mock(() => Promise.resolve(undefined));
+ const client: LspClientInstance = {
+ isClosed: false,
+ ensureFile,
+ query,
+ close,
+ };
+ const clientReady = createDeferred();
+ const clientFactoryStarted = createDeferred();
+ const clientFactory = mock((_options: CreateLspClientOptions): Promise => {
+ clientFactoryStarted.resolve();
+ return clientReady.promise;
+ });
+
+ const manager = new LspManager({
+ registry: createRegistry(),
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ const firstQuery = manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "src/example.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "hover",
+ line: 1,
+ column: 1,
+ });
+ await clientFactoryStarted.promise;
+
+ const secondQuery = manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "src/example.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "hover",
+ line: 1,
+ column: 1,
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(clientFactory).toHaveBeenCalledTimes(1);
+
+ clientReady.resolve(client);
+ const [firstResult, secondResult] = await Promise.all([firstQuery, secondQuery]);
+
+ expect(requireSingleRootQueryResult(firstResult).hover).toBe("const value: 1");
+ expect(requireSingleRootQueryResult(secondResult).hover).toBe("const value: 1");
+ expect(ensureFile).toHaveBeenCalledTimes(2);
+
+ await manager.dispose();
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ test("collects post-mutation diagnostics, clears empty publishes, and clears workspace cache on dispose", async () => {
+ const ensureFile = mock((file: Parameters[0]) => {
+ const version = ensureFile.mock.calls.length;
+ const diagnostics = version === 1 ? [createDiagnostic("first pass")] : [];
+ clientOptions?.onPublishDiagnostics?.({
+ uri: file.uri,
+ version,
+ diagnostics,
+ rawDiagnosticCount: diagnostics.length,
+ });
+ return Promise.resolve(version);
+ });
+ const close = mock(() => Promise.resolve(undefined));
+ let clientOptions: CreateLspClientOptions | undefined;
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ clientOptions = options;
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile,
+ query: mock(() => Promise.resolve({ operation: "hover" as const, hover: "unused" })),
+ close,
+ });
+ });
+
+ const manager = new LspManager({
+ registry: createRegistry(),
+ clientFactory,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+
+ const first = await manager.collectPostMutationDiagnostics({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePaths: ["src/example.ts"],
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ timeoutMs: 20,
+ });
+ expect(first).toHaveLength(1);
+ expect(first[0]?.diagnostics[0]?.message).toBe("first pass");
+
+ const second = await manager.collectPostMutationDiagnostics({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePaths: ["src/example.ts"],
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ timeoutMs: 20,
+ });
+ expect(second).toEqual([]);
+
+ const workspaceDiagnostics = (
+ manager as unknown as {
+ workspaceDiagnostics: Map>>;
+ }
+ ).workspaceDiagnostics;
+ expect(workspaceDiagnostics.get("ws-1")?.values().next().value?.size ?? 0).toBe(0);
+
+ await manager.disposeWorkspace("ws-1");
+ expect(workspaceDiagnostics.has("ws-1")).toBe(false);
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ test("polls tracked files so out-of-band saves refresh diagnostics even when the cache starts empty", async () => {
+ const trackedFiles = new Map<
+ string,
+ {
+ fileHandle: Parameters[0];
+ text: string;
+ version: number;
+ }
+ >();
+ let clientOptions: CreateLspClientOptions | undefined;
+ const ensureFile = mock(async (file: Parameters[0]) => {
+ const text = await fs.readFile(file.readablePath, "utf8");
+ const existing = trackedFiles.get(file.uri);
+ const nextVersion = (existing?.version ?? 0) + 1;
+ trackedFiles.set(file.uri, {
+ fileHandle: { ...file },
+ text,
+ version: nextVersion,
+ });
+ if (!existing || existing.text === text) {
+ return nextVersion;
+ }
+
+ const diagnostics = text.includes('"oops"') ? [createDiagnostic("poll refresh")] : [];
+ clientOptions?.onPublishDiagnostics?.({
+ uri: file.uri,
+ version: nextVersion,
+ diagnostics,
+ rawDiagnosticCount: diagnostics.length,
+ });
+ return nextVersion;
+ });
+ const close = mock(() => Promise.resolve(undefined));
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ clientOptions = options;
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile,
+ getTrackedFiles: () =>
+ [...trackedFiles.values()].map(({ fileHandle }) => ({
+ ...fileHandle,
+ })),
+ query: mock(() => Promise.resolve({ operation: "hover" as const, hover: "unused" })),
+ close,
+ });
+ });
+
+ const manager = new LspManager({
+ registry: createRegistry(),
+ clientFactory,
+ diagnosticPollIntervalMs: 10,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+ const snapshots: WorkspaceLspDiagnosticsSnapshot[] = [];
+ const unsubscribe = manager.subscribeWorkspaceDiagnostics("ws-1", (snapshot) => {
+ snapshots.push(snapshot);
+ });
+
+ await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "src/example.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "hover",
+ line: 1,
+ column: 1,
+ });
+ expect(manager.getWorkspaceDiagnosticsSnapshot("ws-1").diagnostics).toEqual([]);
+
+ await fs.writeFile(
+ path.join(workspacePath, "src", "example.ts"),
+ 'const value: number = "oops";\n'
+ );
+
+ const sawPollRefresh = await waitUntil(() => {
+ return (
+ manager.getWorkspaceDiagnosticsSnapshot("ws-1").diagnostics[0]?.diagnostics[0]?.message ===
+ "poll refresh"
+ );
+ }, 500);
+ expect(sawPollRefresh).toBe(true);
+ expect(snapshots.at(-1)?.diagnostics[0]?.diagnostics[0]?.message).toBe("poll refresh");
+
+ unsubscribe();
+ await manager.dispose();
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ test("does not keep tracked workspaces warm when no diagnostics listeners are active", async () => {
+ const trackedFiles = new Map<
+ string,
+ {
+ fileHandle: Parameters[0];
+ version: number;
+ }
+ >();
+ const ensureFile = mock((file: Parameters[0]) => {
+ const nextVersion = (trackedFiles.get(file.uri)?.version ?? 0) + 1;
+ trackedFiles.set(file.uri, {
+ fileHandle: { ...file },
+ version: nextVersion,
+ });
+ return Promise.resolve(nextVersion);
+ });
+ const close = mock(() => Promise.resolve(undefined));
+ const clientFactory = mock((_options: CreateLspClientOptions): Promise => {
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile,
+ getTrackedFiles: () =>
+ [...trackedFiles.values()].map(({ fileHandle }) => ({
+ ...fileHandle,
+ })),
+ query: mock(() => Promise.resolve({ operation: "hover" as const, hover: "unused" })),
+ close,
+ });
+ });
+
+ const manager = new LspManager({
+ registry: createRegistry(),
+ clientFactory,
+ idleTimeoutMs: 20,
+ idleCheckIntervalMs: 5,
+ diagnosticPollIntervalMs: 10,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+ const workspaceClients = (
+ manager as unknown as {
+ workspaceClients: Map;
+ }
+ ).workspaceClients;
+
+ await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "src/example.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "hover",
+ line: 1,
+ column: 1,
+ });
+
+ const disposed = await waitUntil(() => !workspaceClients.has("ws-1"), 200);
+ expect(disposed).toBe(true);
+ expect(ensureFile).toHaveBeenCalledTimes(1);
+ expect(close).toHaveBeenCalledTimes(1);
+
+ await manager.dispose();
+ });
+
+ test("clears stale diagnostics and stops polling files that disappear out of band", async () => {
+ const trackedFiles = new Map<
+ string,
+ {
+ fileHandle: Parameters[0];
+ text: string;
+ version: number;
+ }
+ >();
+ const ensureFileCalls = new Map();
+ let clientOptions: CreateLspClientOptions | undefined;
+ const ensureFile = mock(async (file: Parameters[0]) => {
+ ensureFileCalls.set(file.uri, (ensureFileCalls.get(file.uri) ?? 0) + 1);
+
+ const text = await fs.readFile(file.readablePath, "utf8");
+ const existing = trackedFiles.get(file.uri);
+ const nextVersion = existing?.text === text ? existing.version : (existing?.version ?? 0) + 1;
+ trackedFiles.set(file.uri, {
+ fileHandle: { ...file },
+ text,
+ version: nextVersion,
+ });
+
+ if (existing?.text !== text) {
+ const diagnostics = text.includes('"oops"') ? [createDiagnostic("stale diagnostic")] : [];
+ clientOptions?.onPublishDiagnostics?.({
+ uri: file.uri,
+ version: nextVersion,
+ diagnostics,
+ rawDiagnosticCount: diagnostics.length,
+ });
+ }
+
+ return nextVersion;
+ });
+ const closeTrackedFile = mock((uri: string) => {
+ trackedFiles.delete(uri);
+ return Promise.resolve();
+ });
+ const close = mock(() => Promise.resolve(undefined));
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ clientOptions = options;
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile,
+ closeTrackedFile,
+ getTrackedFiles: () =>
+ [...trackedFiles.values()].map(({ fileHandle }) => ({
+ ...fileHandle,
+ })),
+ query: mock(() => Promise.resolve({ operation: "hover" as const, hover: "unused" })),
+ close,
+ });
+ });
+
+ const manager = new LspManager({
+ registry: createRegistry(),
+ clientFactory,
+ diagnosticPollIntervalMs: 10,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+ const filePath = path.join(workspacePath, "src", "example.ts");
+ const unsubscribe = manager.subscribeWorkspaceDiagnostics("ws-1", () => undefined);
+
+ await fs.writeFile(filePath, 'const value: number = "oops";\n');
+ await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "src/example.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "hover",
+ line: 1,
+ column: 1,
+ });
+
+ expect(
+ manager.getWorkspaceDiagnosticsSnapshot("ws-1").diagnostics[0]?.diagnostics[0]?.message
+ ).toBe("stale diagnostic");
+
+ const trackedFile = trackedFiles.values().next().value?.fileHandle;
+ if (!trackedFile) {
+ throw new Error("Expected the test client to track the opened file");
+ }
+
+ await fs.rm(filePath);
+
+ const staleDiagnosticsCleared = await waitUntil(() => {
+ return manager.getWorkspaceDiagnosticsSnapshot("ws-1").diagnostics.length === 0;
+ }, 500);
+ expect(staleDiagnosticsCleared).toBe(true);
+ expect(closeTrackedFile).toHaveBeenCalledTimes(1);
+
+ const ensureCallsAfterCleanup = ensureFileCalls.get(trackedFile.uri) ?? 0;
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ expect(ensureFileCalls.get(trackedFile.uri) ?? 0).toBe(ensureCallsAfterCleanup);
+
+ unsubscribe();
+ await manager.dispose();
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ test("serializes poll-driven and explicit refreshes for the same tracked file", async () => {
+ const trackedFiles = new Map<
+ string,
+ {
+ fileHandle: Parameters[0];
+ text: string;
+ version: number;
+ }
+ >();
+ const releaseEnsure = createDeferred();
+ const ensureStarted = createDeferred();
+ let shouldBlockEnsure = false;
+ let blockedEnsureStarted = false;
+ let activeEnsureCalls = 0;
+ let maxActiveEnsureCalls = 0;
+ let clientOptions: CreateLspClientOptions | undefined;
+ const ensureFile = mock(async (file: Parameters[0]) => {
+ activeEnsureCalls += 1;
+ maxActiveEnsureCalls = Math.max(maxActiveEnsureCalls, activeEnsureCalls);
+
+ try {
+ const text = await fs.readFile(file.readablePath, "utf8");
+ const existing = trackedFiles.get(file.uri);
+ if (shouldBlockEnsure && !blockedEnsureStarted) {
+ blockedEnsureStarted = true;
+ ensureStarted.resolve();
+ await releaseEnsure.promise;
+ }
+
+ const nextVersion =
+ existing?.text === text ? existing.version : (existing?.version ?? 0) + 1;
+ trackedFiles.set(file.uri, {
+ fileHandle: { ...file },
+ text,
+ version: nextVersion,
+ });
+
+ if (existing?.text !== text) {
+ const diagnostics = text.includes('"oops"')
+ ? [createDiagnostic("serialized refresh")]
+ : [];
+ clientOptions?.onPublishDiagnostics?.({
+ uri: file.uri,
+ version: nextVersion,
+ diagnostics,
+ rawDiagnosticCount: diagnostics.length,
+ });
+ }
+
+ return nextVersion;
+ } finally {
+ activeEnsureCalls -= 1;
+ }
+ });
+ const close = mock(() => Promise.resolve(undefined));
+ const clientFactory = mock((options: CreateLspClientOptions): Promise => {
+ clientOptions = options;
+ return Promise.resolve({
+ isClosed: false,
+ ensureFile,
+ closeTrackedFile: mock((uri: string) => {
+ trackedFiles.delete(uri);
+ return Promise.resolve();
+ }),
+ getTrackedFiles: () =>
+ [...trackedFiles.values()].map(({ fileHandle }) => ({
+ ...fileHandle,
+ })),
+ query: mock(() => Promise.resolve({ operation: "hover" as const, hover: "unused" })),
+ close,
+ });
+ });
+
+ const manager = new LspManager({
+ registry: createRegistry(),
+ clientFactory,
+ diagnosticPollIntervalMs: 10,
+ });
+ const runtime = new LocalRuntime(workspacePath);
+ const filePath = path.join(workspacePath, "src", "example.ts");
+ const unsubscribe = manager.subscribeWorkspaceDiagnostics("ws-1", () => undefined);
+
+ await manager.query({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePath: "src/example.ts",
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ operation: "hover",
+ line: 1,
+ column: 1,
+ });
+ await fs.writeFile(filePath, 'const value: number = "oops";\n');
+
+ shouldBlockEnsure = true;
+ const diagnosticsPromise = manager.collectPostMutationDiagnostics({
+ workspaceId: "ws-1",
+ runtime,
+ workspacePath,
+ filePaths: ["src/example.ts"],
+ policyContext: TEST_LSP_POLICY_CONTEXT,
+ timeoutMs: 200,
+ });
+
+ await ensureStarted.promise;
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ expect(maxActiveEnsureCalls).toBe(1);
+
+ shouldBlockEnsure = false;
+ releaseEnsure.resolve();
+
+ const diagnostics = await diagnosticsPromise;
+ expect(maxActiveEnsureCalls).toBe(1);
+ expect(diagnostics[0]?.diagnostics[0]?.message).toBe("serialized refresh");
+
+ unsubscribe();
+ await manager.dispose();
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ test("idle cleanup does not dispose a workspace while a poll refresh is active", async () => {
+ const trackedFiles = new Map<
+ string,
+ {
+ fileHandle: Parameters