@dyyz1993/pi-coding-agent 0.74.27 → 0.74.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/core/agent-session.d.ts +4 -0
  2. package/dist/core/agent-session.d.ts.map +1 -1
  3. package/dist/core/agent-session.js +58 -0
  4. package/dist/core/agent-session.js.map +1 -1
  5. package/dist/core/extensions/channel-registry.d.ts +2 -0
  6. package/dist/core/extensions/channel-registry.d.ts.map +1 -1
  7. package/dist/core/extensions/channel-registry.js.map +1 -1
  8. package/dist/core/extensions/types.d.ts +17 -1
  9. package/dist/core/extensions/types.d.ts.map +1 -1
  10. package/dist/core/extensions/types.js.map +1 -1
  11. package/dist/core/session-manager.d.ts +5 -0
  12. package/dist/core/session-manager.d.ts.map +1 -1
  13. package/dist/core/session-manager.js +44 -1
  14. package/dist/core/session-manager.js.map +1 -1
  15. package/dist/extensions/bash-ext/index.ts +86 -62
  16. package/dist/extensions/file-snapshot/index.ts +4 -1
  17. package/dist/extensions/hooks-engine/index.ts +104 -16
  18. package/dist/extensions/lsp/lsp/index.ts +21 -3
  19. package/dist/extensions/lsp/lsp/utils/project-scanner.ts +102 -0
  20. package/dist/extensions/rules-engine/index.js +64 -22
  21. package/dist/extensions/rules-engine/index.ts +86 -16
  22. package/dist/extensions/rules-engine/types.d.ts +12 -2
  23. package/dist/extensions/rules-engine/types.d.ts.map +1 -1
  24. package/dist/extensions/rules-engine/types.js.map +1 -1
  25. package/dist/extensions/rules-engine/types.ts +13 -2
  26. package/dist/extensions/session-supervisor/config.ts +3 -1
  27. package/dist/extensions/session-supervisor/index.ts +90 -63
  28. package/dist/extensions/session-supervisor/types.d.ts +321 -0
  29. package/dist/extensions/session-supervisor/types.d.ts.map +1 -0
  30. package/dist/extensions/session-supervisor/types.js +92 -0
  31. package/dist/extensions/session-supervisor/types.js.map +1 -0
  32. package/dist/extensions/session-supervisor/types.ts +8 -8
  33. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  34. package/dist/modes/rpc/rpc-mode.js +1 -1
  35. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  36. package/package.json +1 -1
@@ -73,6 +73,7 @@ type BashToolDetails = _BashToolDetails & {
73
73
  };
74
74
 
75
75
  const DEFAULT_TIMEOUT_SECONDS = 300;
76
+ const DEFAULT_BACKGROUND_AFTER_SECONDS = 120;
76
77
 
77
78
  const bashSchema = Type.Object({
78
79
  command: Type.String({ description: "Bash command to execute" }),
@@ -179,83 +180,105 @@ export default function (pi: ExtensionAPI) {
179
180
  });
180
181
 
181
182
  channel.handle("kill", ({ toolCallId }) => {
182
- if (!toolCallId) return;
183
+ if (!toolCallId) return { ok: false, reason: "not_found" };
183
184
  const m = managed.get(toolCallId);
184
- if (m?.proc.pid) {
185
- killProcessTree(m.proc.pid);
186
- m.proc.status = "terminated";
187
- m.proc.endedAt = Date.now();
188
- m.resolved = true;
189
- m.killedByUser = true;
190
- const durationMs = m.proc.endedAt - m.proc.startedAt;
191
- if (m.logStream) m.logStream.end();
185
+ if (!m) {
186
+ // Process already exited — emit terminated event so frontend can sync state
192
187
  channel?.emit("terminated", {
193
188
  type: "terminated",
194
189
  toolCallId,
195
- pid: m.proc.pid,
190
+ pid: undefined,
196
191
  processes: Array.from(managed.values()).map((x) => x.proc),
197
192
  timestamp: Date.now(),
198
193
  });
199
- m.resolve({
200
- content: [
201
- {
202
- type: "text",
203
- text: `${m.proc.output || "(no output)"}\n\n[User cancelled after ${formatDuration(durationMs)}, PID: ${m.proc.pid}${m.proc.logPath ? `. Log: ${m.proc.logPath}` : ""}]`,
204
- },
205
- ],
206
- details: {
207
- terminated: {
208
- reason: "user_cancel",
209
- pid: m.proc.pid,
210
- command: m.proc.command,
211
- startedAt: m.proc.startedAt,
212
- endedAt: m.proc.endedAt,
213
- durationMs,
214
- logPath: m.proc.logPath,
215
- },
216
- },
217
- });
194
+ return { ok: true, alreadyExited: true };
218
195
  }
196
+ if (m.proc.pid) {
197
+ killProcessTree(m.proc.pid);
198
+ }
199
+ m.proc.status = "terminated";
200
+ m.proc.endedAt = Date.now();
201
+ m.resolved = true;
202
+ m.killedByUser = true;
203
+ const durationMs = m.proc.endedAt - m.proc.startedAt;
204
+ if (m.logStream) m.logStream.end();
205
+ channel?.emit("terminated", {
206
+ type: "terminated",
207
+ toolCallId,
208
+ pid: m.proc.pid,
209
+ processes: Array.from(managed.values()).map((x) => x.proc),
210
+ timestamp: Date.now(),
211
+ });
212
+ m.resolve({
213
+ content: [
214
+ {
215
+ type: "text",
216
+ text: `${m.proc.output || "(no output)"}\n\n[User cancelled after ${formatDuration(durationMs)}, PID: ${m.proc.pid ?? "unknown"}${m.proc.logPath ? `. Log: ${m.proc.logPath}` : ""}]`,
217
+ },
218
+ ],
219
+ details: {
220
+ terminated: {
221
+ reason: "user_cancel",
222
+ pid: m.proc.pid,
223
+ command: m.proc.command,
224
+ startedAt: m.proc.startedAt,
225
+ endedAt: m.proc.endedAt,
226
+ durationMs,
227
+ logPath: m.proc.logPath,
228
+ },
229
+ },
230
+ });
231
+ return { ok: true };
219
232
  });
220
233
 
221
234
  channel.handle("background", ({ toolCallId }) => {
222
- if (!toolCallId) return;
235
+ if (!toolCallId) return { ok: false, reason: "not_found" };
223
236
  const m = managed.get(toolCallId);
224
- if (m) {
225
- m.proc.status = "background";
226
- m.resolved = true;
227
- m.backgrounded = true;
228
- m.outputSubscribed = false;
229
- createLogStream(m);
230
- const durationMs = Date.now() - m.proc.startedAt;
231
- channel?.emit("background", {
232
- type: "background",
237
+ if (!m) {
238
+ // Process already exited — emit terminated event so frontend can sync state
239
+ channel?.emit("terminated", {
240
+ type: "terminated",
233
241
  toolCallId,
234
- pid: m.proc.pid,
235
- data: m.proc.output.slice(-2000),
242
+ pid: undefined,
236
243
  processes: Array.from(managed.values()).map((x) => x.proc),
237
244
  timestamp: Date.now(),
238
245
  });
239
- const outputPreview = m.proc.output ? takeLastLines(m.proc.output, BG_PREVIEW_LINES) : "(no output yet)";
240
- m.resolve({
241
- content: [
242
- {
243
- type: "text",
244
- text: `${outputPreview}\n\n[Moved to background after ${formatDuration(durationMs)}, PID: ${m.proc.pid ?? "unknown"}. <bashId>${m.proc.bashId}</bashId>. Log: ${m.proc.logPath}. Use get_background_process with <bashId>${m.proc.bashId}</bashId> to check progress.]`,
245
- },
246
- ],
247
- details: {
248
- background: {
249
- pid: m.proc.pid,
250
- command: m.proc.command,
251
- startedAt: m.proc.startedAt,
252
- durationMs,
253
- logPath: m.proc.logPath,
254
- detached: false,
255
- },
256
- },
257
- });
246
+ return { ok: true, alreadyExited: true };
258
247
  }
248
+ m.proc.status = "background";
249
+ m.resolved = true;
250
+ m.backgrounded = true;
251
+ m.outputSubscribed = false;
252
+ createLogStream(m);
253
+ const durationMs = Date.now() - m.proc.startedAt;
254
+ channel?.emit("background", {
255
+ type: "background",
256
+ toolCallId,
257
+ pid: m.proc.pid,
258
+ data: m.proc.output.slice(-2000),
259
+ processes: Array.from(managed.values()).map((x) => x.proc),
260
+ timestamp: Date.now(),
261
+ });
262
+ const outputPreview = m.proc.output ? takeLastLines(m.proc.output, BG_PREVIEW_LINES) : "(no output yet)";
263
+ m.resolve({
264
+ content: [
265
+ {
266
+ type: "text",
267
+ text: `${outputPreview}\n\n[Moved to background after ${formatDuration(durationMs)}, PID: ${m.proc.pid ?? "unknown"}. <bashId>${m.proc.bashId}</bashId>. Log: ${m.proc.logPath}. Use get_background_process with <bashId>${m.proc.bashId}</bashId> to check progress.]`,
268
+ },
269
+ ],
270
+ details: {
271
+ background: {
272
+ pid: m.proc.pid,
273
+ command: m.proc.command,
274
+ startedAt: m.proc.startedAt,
275
+ durationMs,
276
+ logPath: m.proc.logPath,
277
+ detached: false,
278
+ },
279
+ },
280
+ });
281
+ return { ok: true };
259
282
  });
260
283
 
261
284
  channel.handle("subscribe_output", ({ toolCallId }) => {
@@ -318,8 +341,9 @@ export default function (pi: ExtensionAPI) {
318
341
  _ctx?: ExtensionContext,
319
342
  ): Promise<AgentToolResult<BashToolDetails>> {
320
343
  return new Promise((resolve, reject) => {
321
- const effectiveTimeout = timeout ?? DEFAULT_TIMEOUT_SECONDS;
322
- const effectiveBackgroundAfter = backgroundAfter !== undefined && backgroundAfter < effectiveTimeout ? backgroundAfter : undefined;
344
+ const effectiveTimeout = timeout ?? DEFAULT_TIMEOUT_SECONDS;
345
+ const rawBackgroundAfter = backgroundAfter ?? DEFAULT_BACKGROUND_AFTER_SECONDS;
346
+ const effectiveBackgroundAfter = rawBackgroundAfter < effectiveTimeout ? rawBackgroundAfter : undefined;
323
347
  const cwd = cwdParam ?? _ctx?.cwd ?? process.cwd();
324
348
  const bashId = generateBashId();
325
349
 
@@ -4,7 +4,10 @@ export default function fileSnapshot(pi: ExtensionAPI) {
4
4
  const channel = pi.registerChannel("file-snapshot");
5
5
 
6
6
  channel.onReceive(async (msg) => {
7
- const ctx = msg.context as ExtensionContext;
7
+ const ctx = msg.context as ExtensionContext | undefined;
8
+ if (!ctx) {
9
+ return { error: "Extension context not available in channel message. This operation is not supported via RPC client channel calls." };
10
+ }
8
11
  const mgr = ctx.fileSnapshotManager;
9
12
  if (!mgr) {
10
13
  return { error: "fileSnapshotManager not available" };
@@ -7,7 +7,7 @@
7
7
  * Supported events: tool_call, tool_result, agent_start, agent_end
8
8
  * Supported hook types: command (spawn process), prompt (inject text)
9
9
  *
10
- * Command hooks: exit code 2 = block operation, 0 = allow
10
+ * Command hooks: exit code 2 = block operation, 0 = allow, 3 = ask user
11
11
  * Prompt hooks: text injected into the conversation
12
12
  */
13
13
 
@@ -23,7 +23,18 @@ const EVENT_MAP: Record<string, string> = {
23
23
  session_shutdown: "on_session_end",
24
24
  };
25
25
 
26
- function parseHooks(raw: string | undefined): AgentHooks | null {
26
+ // Export types for testing
27
+ export type { HookResult };
28
+
29
+ interface HookResult {
30
+ action: "allow" | "deny" | "ask";
31
+ reason?: string;
32
+ question?: string;
33
+ options?: string[];
34
+ message?: string;
35
+ }
36
+
37
+ export function parseHooks(raw: string | undefined): AgentHooks | null {
27
38
  if (!raw) return null;
28
39
  try {
29
40
  return JSON.parse(raw);
@@ -32,21 +43,38 @@ function parseHooks(raw: string | undefined): AgentHooks | null {
32
43
  }
33
44
  }
34
45
 
35
- function matchesCondition(condition: string | undefined, event: Record<string, unknown>): boolean {
46
+ export function matchesCondition(condition: string | undefined, event: Record<string, unknown>): boolean {
36
47
  if (!condition) return true;
37
48
  const toolName = (event.toolName as string) ?? "";
38
- const parts = condition.split("|").map(s => s.trim());
49
+ const parts = condition.split("|").map((s) => s.trim());
39
50
  return parts.includes(toolName);
40
51
  }
41
52
 
42
- async function executeCommand(command: string, event: Record<string, unknown>, timeout = 5000): Promise<number> {
53
+ export async function executeCommand(
54
+ command: string,
55
+ event: Record<string, unknown>,
56
+ timeout = 5000,
57
+ ): Promise<{ exitCode: number; stdout: string }> {
43
58
  return new Promise((resolve) => {
44
59
  const toolName = (event.toolName as string) ?? "";
60
+ const toolCallId = (event.toolCallId as string) ?? "";
45
61
  const input = event.input ?? {};
62
+ const vars = event.variables as Record<string, string> | undefined;
63
+
46
64
  const env: Record<string, string> = {
47
65
  ...process.env as Record<string, string>,
66
+ // Tool context
48
67
  PI_HOOK_TOOL: toolName,
49
- PI_HOOK_EVENT: JSON.stringify(input),
68
+ PI_HOOK_TOOL_CALL_ID: toolCallId,
69
+ PI_HOOK_INPUT: JSON.stringify(input),
70
+ // Agent context (from event.variables)
71
+ PI_HOOK_AGENT_NAME: vars?.agentName ?? "",
72
+ PI_HOOK_PERMISSION_MODE: vars?.permissionMode ?? "",
73
+ PI_HOOK_ALLOWED_TOOLS: vars?.allowedTools ?? "",
74
+ PI_HOOK_DISALLOWED_TOOLS: vars?.disallowedTools ?? "",
75
+ // Session context (if available)
76
+ PI_HOOK_SESSION_ID: (event as any).sessionId ?? "",
77
+ PI_HOOK_CWD: (event as any).cwd ?? "",
50
78
  };
51
79
 
52
80
  const proc = spawn("sh", ["-c", command], {
@@ -54,29 +82,52 @@ async function executeCommand(command: string, event: Record<string, unknown>, t
54
82
  stdio: ["pipe", "pipe", "pipe"],
55
83
  });
56
84
 
85
+ let stdout = "";
86
+ let stderr = "";
87
+
88
+ proc.stdout?.on("data", (data) => {
89
+ stdout += String(data);
90
+ });
91
+
92
+ proc.stderr?.on("data", (data) => {
93
+ stderr += String(data);
94
+ });
95
+
57
96
  const timer = setTimeout(() => {
58
97
  proc.kill("SIGTERM");
59
- resolve(0);
98
+ resolve({ exitCode: 0, stdout: "" });
60
99
  }, timeout);
61
100
 
62
101
  proc.on("close", (code) => {
63
102
  clearTimeout(timer);
64
- resolve(code ?? 0);
103
+ resolve({ exitCode: code ?? 0, stdout });
65
104
  });
66
105
 
67
106
  proc.on("error", () => {
68
107
  clearTimeout(timer);
69
- resolve(0);
108
+ resolve({ exitCode: 0, stdout: "" });
70
109
  });
71
110
  });
72
111
  }
73
112
 
113
+ export function parseStdout(stdout: string): HookResult | null {
114
+ if (!stdout.trim()) {
115
+ return null;
116
+ }
117
+
118
+ try {
119
+ return JSON.parse(stdout.trim()) as HookResult;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
74
125
  export default function hooksEngine(pi: ExtensionAPI): void {
75
126
  const subscribe = (eventName: string) => {
76
127
  const hookKey = EVENT_MAP[eventName];
77
128
  if (!hookKey) return;
78
129
 
79
- pi.on(eventName, async (event: Record<string, unknown>) => {
130
+ pi.on(eventName, async (event: Record<string, unknown>, ctx: any) => {
80
131
  const vars = event.variables as Record<string, string> | undefined;
81
132
  if (!vars?.agentHooks) return undefined;
82
133
 
@@ -86,25 +137,62 @@ export default function hooksEngine(pi: ExtensionAPI): void {
86
137
  const eventHooks = hooks[hookKey] ?? hooks["*"] ?? [];
87
138
  if (eventHooks.length === 0) return undefined;
88
139
 
89
- const results: string[] = [];
140
+ const promptResults: string[] = [];
90
141
 
91
142
  for (const hook of eventHooks) {
92
143
  if (!matchesCondition(hook.if, event)) continue;
93
144
 
94
145
  if (hook.type === "command") {
95
- const code = await executeCommand(hook.command, event);
96
- if (code === 2) {
146
+ const { exitCode, stdout } = await executeCommand(hook.command, event);
147
+
148
+ if (exitCode === 0 && stdout.trim()) {
149
+ const parsed = parseStdout(stdout);
150
+ if (parsed?.action === "allow" && parsed.message) {
151
+ console.log("[hook] Context injection:", parsed.message);
152
+ } else if (!parsed && stdout.trim()) {
153
+ console.log("[hook] Message:", stdout.trim());
154
+ }
155
+ continue;
156
+ }
157
+
158
+ if (exitCode === 2) {
159
+ const parsed = parseStdout(stdout);
160
+ const reason = parsed?.reason ?? stdout.trim() ?? `[hook] Operation blocked by hook: ${hook.command}`;
97
161
  return {
98
162
  block: true,
99
- reason: `[hook] Operation blocked by hook: ${hook.command}`,
163
+ reason,
100
164
  };
101
165
  }
166
+
167
+ if (exitCode === 3) {
168
+ const parsed = parseStdout(stdout);
169
+ const question = parsed?.question ?? stdout.trim() ?? "Confirm this operation?";
170
+
171
+ if (ctx?.ui?.confirm) {
172
+ const confirmed = await ctx.ui.confirm(question, "no");
173
+ if (!confirmed) {
174
+ return {
175
+ block: true,
176
+ reason: "[hook] User denied the operation",
177
+ };
178
+ }
179
+ continue;
180
+ } else {
181
+ return {
182
+ block: true,
183
+ reason: "[hook] Ask confirmation not supported in this context",
184
+ };
185
+ }
186
+ }
187
+
188
+ continue;
102
189
  } else if (hook.type === "prompt") {
103
- results.push(hook.prompt);
190
+ promptResults.push(hook.prompt);
104
191
  }
105
192
  }
106
193
 
107
- if (results.length > 0) {
194
+ if (promptResults.length > 0) {
195
+ console.log("[hook] Prompts to inject:", promptResults);
108
196
  }
109
197
 
110
198
  return undefined;
@@ -11,6 +11,7 @@ import { createDependencyResolver } from "./utils/dependency-resolver.js";
11
11
  import { createWriteThroughHooks } from "./hooks/writethrough.js";
12
12
  import { createLspToolRouter } from "./tools/lsp-tool.js";
13
13
  import { createServerMetricsCollector } from "./monitoring/server-metrics.js";
14
+ import { scanProjectFileTypes, filterServersByProject } from "./utils/project-scanner.js";
14
15
 
15
16
  export interface LspChannelEvent {
16
17
  event:
@@ -143,14 +144,31 @@ export default function lspExtension(pi: ExtensionAPI): void {
143
144
 
144
145
  const config = configResolver.resolve();
145
146
 
147
+ // Scan project for file types and filter servers
148
+ const cwd = process.cwd();
149
+ const scanResult = scanProjectFileTypes(cwd);
150
+ const filteredServers = filterServersByProject(config.servers, scanResult);
151
+ const skippedNames = config.servers
152
+ .filter((s) => !filteredServers.some((f) => f.name === s.name))
153
+ .map((s) => s.name);
154
+ const discoveredExts = [...scanResult.discoveredExtensions].sort();
155
+
156
+ if (skippedNames.length > 0) {
157
+ console.log(
158
+ `[lsp] Project scan found [${discoveredExts.join(", ")}], starting ${filteredServers.length}/${config.servers.length} servers (skipped: ${skippedNames.join(", ")})`,
159
+ );
160
+ }
161
+
162
+ const filteredConfig = { ...config, servers: filteredServers };
163
+
146
164
  lspChannel?.emit("startup_begin", {
147
165
  event: "startup_begin",
148
166
  timestamp: Date.now(),
149
- servers: config.servers.map((s) => ({ name: s.name, state: "starting", fileTypes: s.fileTypes })),
150
- totalServers: config.servers.length,
167
+ servers: filteredConfig.servers.map((s) => ({ name: s.name, state: "starting", fileTypes: s.fileTypes })),
168
+ totalServers: filteredConfig.servers.length,
151
169
  });
152
170
 
153
- await runtime.start(config);
171
+ await runtime.start(filteredConfig);
154
172
  const status = runtime.getStatus();
155
173
 
156
174
  for (const srv of status.servers) {
@@ -0,0 +1,102 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { extname, join } from "node:path";
4
+ import type { ResolvedLspServerConfig } from "../config/resolver.js";
5
+
6
+ export interface ProjectScanResult {
7
+ discoveredExtensions: Set<string>;
8
+ }
9
+
10
+ /**
11
+ * Scan the project for file types present on disk.
12
+ * Uses `git ls-files` when available (fast, respects .gitignore),
13
+ * falls back to a shallow `find` otherwise.
14
+ */
15
+ export function scanProjectFileTypes(cwd: string): ProjectScanResult {
16
+ const extensions = new Set<string>();
17
+
18
+ // Strategy 1: git ls-files (fast, respects gitignore)
19
+ const gitFiles = tryGitLsFiles(cwd);
20
+ if (gitFiles.length > 0) {
21
+ for (const file of gitFiles) {
22
+ const ext = extname(file).toLowerCase();
23
+ if (ext) {
24
+ extensions.add(ext);
25
+ }
26
+ }
27
+ return { discoveredExtensions: extensions };
28
+ }
29
+
30
+ // Strategy 2: shallow find (maxdepth 3, skip node_modules etc.)
31
+ try {
32
+ const output = execSync(
33
+ 'find . -maxdepth 3 -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/target/*" -not -path "*/dist/*" -not -path "*/.pi/*" 2>/dev/null | head -2000',
34
+ { cwd, timeout: 3000, encoding: "utf8" },
35
+ );
36
+ for (const line of output.split("\n")) {
37
+ const trimmed = line.trim();
38
+ if (!trimmed) continue;
39
+ const ext = extname(trimmed).toLowerCase();
40
+ if (ext) {
41
+ extensions.add(ext);
42
+ }
43
+ }
44
+ } catch {
45
+ // If scan fails, return empty — will fall back to starting all servers
46
+ }
47
+
48
+ return { discoveredExtensions: extensions };
49
+ }
50
+
51
+ function tryGitLsFiles(cwd: string): string[] {
52
+ // Check if we're in a git repo
53
+ if (!existsSync(join(cwd, ".git"))) {
54
+ return [];
55
+ }
56
+
57
+ try {
58
+ const output = execSync("git ls-files --cached --others --exclude-standard 2>/dev/null | head -2000", {
59
+ cwd,
60
+ timeout: 3000,
61
+ encoding: "utf8",
62
+ });
63
+ const files = output.split("\n").filter(Boolean);
64
+ return files.length > 0 ? files : [];
65
+ } catch {
66
+ return [];
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Filter server configs to only those whose fileTypes match files in the project.
72
+ * Servers WITHOUT fileTypes (catch-all) are always included.
73
+ * If the project has no discoverable files, all servers are started (safe fallback).
74
+ */
75
+ export function filterServersByProject(
76
+ servers: ResolvedLspServerConfig[],
77
+ scanResult: ProjectScanResult,
78
+ ): ResolvedLspServerConfig[] {
79
+ const { discoveredExtensions } = scanResult;
80
+
81
+ // Safe fallback: if scan found nothing, start everything
82
+ if (discoveredExtensions.size === 0) {
83
+ return servers;
84
+ }
85
+
86
+ const filtered: ResolvedLspServerConfig[] = [];
87
+ for (const server of servers) {
88
+ // No fileTypes = catch-all server, always include
89
+ if (!server.fileTypes || server.fileTypes.length === 0) {
90
+ filtered.push(server);
91
+ continue;
92
+ }
93
+
94
+ // Include if ANY of the server's fileTypes exist in the project
95
+ const hasMatch = server.fileTypes.some((ft) => discoveredExtensions.has(ft.toLowerCase()));
96
+ if (hasMatch) {
97
+ filtered.push(server);
98
+ }
99
+ }
100
+
101
+ return filtered;
102
+ }