@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.
- package/dist/core/agent-session.d.ts +4 -0
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +58 -0
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/extensions/channel-registry.d.ts +2 -0
- package/dist/core/extensions/channel-registry.d.ts.map +1 -1
- package/dist/core/extensions/channel-registry.js.map +1 -1
- package/dist/core/extensions/types.d.ts +17 -1
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/session-manager.d.ts +5 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +44 -1
- package/dist/core/session-manager.js.map +1 -1
- package/dist/extensions/bash-ext/index.ts +86 -62
- package/dist/extensions/file-snapshot/index.ts +4 -1
- package/dist/extensions/hooks-engine/index.ts +104 -16
- package/dist/extensions/lsp/lsp/index.ts +21 -3
- package/dist/extensions/lsp/lsp/utils/project-scanner.ts +102 -0
- package/dist/extensions/rules-engine/index.js +64 -22
- package/dist/extensions/rules-engine/index.ts +86 -16
- package/dist/extensions/rules-engine/types.d.ts +12 -2
- package/dist/extensions/rules-engine/types.d.ts.map +1 -1
- package/dist/extensions/rules-engine/types.js.map +1 -1
- package/dist/extensions/rules-engine/types.ts +13 -2
- package/dist/extensions/session-supervisor/config.ts +3 -1
- package/dist/extensions/session-supervisor/index.ts +90 -63
- package/dist/extensions/session-supervisor/types.d.ts +321 -0
- package/dist/extensions/session-supervisor/types.d.ts.map +1 -0
- package/dist/extensions/session-supervisor/types.js +92 -0
- package/dist/extensions/session-supervisor/types.js.map +1 -0
- package/dist/extensions/session-supervisor/types.ts +8 -8
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +1 -1
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- 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
|
|
185
|
-
|
|
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:
|
|
190
|
+
pid: undefined,
|
|
196
191
|
processes: Array.from(managed.values()).map((x) => x.proc),
|
|
197
192
|
timestamp: Date.now(),
|
|
198
193
|
});
|
|
199
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
96
|
-
|
|
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
|
|
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
|
-
|
|
190
|
+
promptResults.push(hook.prompt);
|
|
104
191
|
}
|
|
105
192
|
}
|
|
106
193
|
|
|
107
|
-
if (
|
|
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:
|
|
150
|
-
totalServers:
|
|
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(
|
|
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
|
+
}
|