Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { spawn } from 'child_process';
7
import type { CustomAgentConfig, MCPServerConfig, SessionConfig } from '@github/copilot-sdk';
8
import { OperatingSystem, OS } from '../../../../base/common/platform.js';
9
import { IFileService } from '../../../files/common/files.js';
10
import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js';
11
import type { IMcpServerDefinition, INamedPluginResource, IParsedHookCommand, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js';
12
import { dirname } from '../../../../base/common/path.js';
13
14
type SessionHooks = NonNullable<SessionConfig['hooks']>;
15
type PreToolUseHookInput = Parameters<NonNullable<SessionHooks['onPreToolUse']>>[0];
16
type PostToolUseHookInput = Parameters<NonNullable<SessionHooks['onPostToolUse']>>[0];
17
type UserPromptSubmittedHookInput = Parameters<NonNullable<SessionHooks['onUserPromptSubmitted']>>[0];
18
type SessionStartHookInput = Parameters<NonNullable<SessionHooks['onSessionStart']>>[0];
19
type SessionEndHookInput = Parameters<NonNullable<SessionHooks['onSessionEnd']>>[0];
20
type ErrorOccurredHookInput = Parameters<NonNullable<SessionHooks['onErrorOccurred']>>[0];
21
22
// ---------------------------------------------------------------------------
23
// MCP servers
24
// ---------------------------------------------------------------------------
25
26
/**
27
* Converts parsed MCP server definitions into the SDK's `mcpServers` config.
28
*/
29
export function toSdkMcpServers(defs: readonly IMcpServerDefinition[]): Record<string, MCPServerConfig> {
30
const result: Record<string, MCPServerConfig> = {};
31
for (const def of defs) {
32
const config = def.configuration;
33
if (config.type === McpServerType.LOCAL) {
34
result[def.name] = {
35
type: 'local',
36
command: config.command,
37
args: config.args ? [...config.args] : [],
38
tools: ['*'],
39
...(config.env && { env: toStringEnv(config.env) }),
40
...(config.cwd && { cwd: config.cwd }),
41
};
42
} else {
43
result[def.name] = {
44
type: 'http',
45
url: config.url,
46
tools: ['*'],
47
...(config.headers && { headers: { ...config.headers } }),
48
};
49
}
50
}
51
return result;
52
}
53
54
/**
55
* Ensures all env values are strings (the SDK requires `Record<string, string>`).
56
*/
57
function toStringEnv(env: Record<string, string | number | null>): Record<string, string> {
58
const result: Record<string, string> = {};
59
for (const [key, value] of Object.entries(env)) {
60
if (value !== null) {
61
result[key] = String(value);
62
}
63
}
64
return result;
65
}
66
67
// ---------------------------------------------------------------------------
68
// Custom agents
69
// ---------------------------------------------------------------------------
70
71
/**
72
* Converts parsed plugin agents into the SDK's `customAgents` config.
73
* Reads each agent's `.md` file to use as the prompt.
74
*/
75
export async function toSdkCustomAgents(agents: readonly INamedPluginResource[], fileService: IFileService): Promise<CustomAgentConfig[]> {
76
const configs: CustomAgentConfig[] = [];
77
for (const agent of agents) {
78
try {
79
const content = await fileService.readFile(agent.uri);
80
configs.push({
81
name: agent.name,
82
prompt: content.value.toString(),
83
});
84
} catch {
85
// Skip agents whose file cannot be read
86
}
87
}
88
return configs;
89
}
90
91
// ---------------------------------------------------------------------------
92
// Skill directories
93
// ---------------------------------------------------------------------------
94
95
/**
96
* Converts parsed plugin skills into the SDK's `skillDirectories` config.
97
* The SDK expects directory paths; we extract the parent directory of each SKILL.md.
98
*/
99
export function toSdkSkillDirectories(skills: readonly INamedPluginResource[]): string[] {
100
const seen = new Set<string>();
101
const result: string[] = [];
102
for (const skill of skills) {
103
// SKILL.md parent directory is the skill directory
104
const dir = dirname(skill.uri.fsPath);
105
if (!seen.has(dir)) {
106
seen.add(dir);
107
result.push(dir);
108
}
109
}
110
return result;
111
}
112
113
// ---------------------------------------------------------------------------
114
// Hooks
115
// ---------------------------------------------------------------------------
116
117
/**
118
* Resolves the effective command for the current platform from a parsed hook command.
119
*/
120
function resolveEffectiveCommand(hook: IParsedHookCommand, os: OperatingSystem): string | undefined {
121
if (os === OperatingSystem.Windows && hook.windows) {
122
return hook.windows;
123
} else if (os === OperatingSystem.Macintosh && hook.osx) {
124
return hook.osx;
125
} else if (os === OperatingSystem.Linux && hook.linux) {
126
return hook.linux;
127
}
128
return hook.command;
129
}
130
131
/**
132
* Executes a hook command as a shell process. Returns the stdout on success,
133
* or throws on non-zero exit code or timeout.
134
*/
135
function executeHookCommand(hook: IParsedHookCommand, stdin?: string): Promise<string> {
136
const command = resolveEffectiveCommand(hook, OS);
137
if (!command) {
138
return Promise.resolve('');
139
}
140
141
const timeout = (hook.timeout ?? 30) * 1000;
142
const cwd = hook.cwd?.fsPath;
143
144
return new Promise<string>((resolve, reject) => {
145
const isWindows = OS === OperatingSystem.Windows;
146
const shell = isWindows ? 'cmd.exe' : '/bin/sh';
147
const shellArgs = isWindows ? ['/c', command] : ['-c', command];
148
149
const child = spawn(shell, shellArgs, {
150
cwd,
151
env: { ...process.env, ...hook.env },
152
stdio: ['pipe', 'pipe', 'pipe'],
153
timeout,
154
});
155
156
let stdout = '';
157
let stderr = '';
158
159
child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
160
child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
161
162
if (stdin) {
163
child.stdin.write(stdin);
164
child.stdin.end();
165
} else {
166
child.stdin.end();
167
}
168
169
child.on('error', reject);
170
child.on('close', (code) => {
171
if (code === 0) {
172
resolve(stdout);
173
} else {
174
reject(new Error(`Hook command exited with code ${code}: ${stderr || stdout}`));
175
}
176
});
177
});
178
}
179
180
/**
181
* Runs a list of hook commands sequentially, passing `input` as JSON stdin.
182
* Returns the parsed output of the first command that emits a valid JSON object,
183
* or `undefined` if no command produces parseable JSON output.
184
* Command failures are swallowed — hooks are non-fatal.
185
*/
186
async function runHookCommands(commands: readonly IParsedHookCommand[] | undefined, input: unknown): Promise<object | undefined> {
187
if (!commands) {
188
return undefined;
189
}
190
const stdin = JSON.stringify(input);
191
for (const cmd of commands) {
192
try {
193
const output = await executeHookCommand(cmd, stdin);
194
if (output.trim()) {
195
try {
196
const parsed = JSON.parse(output);
197
if (parsed && typeof parsed === 'object') {
198
return parsed;
199
}
200
} catch {
201
// Non-JSON output is fine — no modification
202
}
203
}
204
} catch {
205
// Hook failures are non-fatal
206
}
207
}
208
return undefined;
209
}
210
211
/**
212
* Mapping from canonical hook type identifiers to SDK SessionHooks handler keys.
213
*/
214
const HOOK_TYPE_TO_SDK_KEY: Record<string, keyof SessionHooks> = {
215
'PreToolUse': 'onPreToolUse',
216
'PostToolUse': 'onPostToolUse',
217
'UserPromptSubmit': 'onUserPromptSubmitted',
218
'SessionStart': 'onSessionStart',
219
'SessionEnd': 'onSessionEnd',
220
'ErrorOccurred': 'onErrorOccurred',
221
};
222
223
/**
224
* Converts parsed plugin hooks into SDK {@link SessionHooks} handler functions.
225
*
226
* Each handler executes the hook's shell commands sequentially when invoked.
227
* Hook types that don't map to SDK handler keys are silently ignored.
228
*
229
* The optional `editTrackingHooks` parameter provides internal edit-tracking
230
* callbacks from {@link CopilotAgentSession} that are merged with plugin hooks.
231
*/
232
export function toSdkHooks(
233
hookGroups: readonly IParsedHookGroup[],
234
editTrackingHooks?: {
235
readonly onPreToolUse: (input: PreToolUseHookInput) => Promise<void>;
236
readonly onPostToolUse: (input: PostToolUseHookInput) => Promise<void>;
237
},
238
): SessionHooks {
239
// Group all commands by SDK handler key
240
const commandsByKey = new Map<keyof SessionHooks, IParsedHookCommand[]>();
241
for (const group of hookGroups) {
242
const sdkKey = HOOK_TYPE_TO_SDK_KEY[group.type];
243
if (!sdkKey) {
244
continue;
245
}
246
const existing = commandsByKey.get(sdkKey) ?? [];
247
existing.push(...group.commands);
248
commandsByKey.set(sdkKey, existing);
249
}
250
251
const hooks: SessionHooks = {};
252
253
// Pre-tool-use handler
254
const preToolCommands = commandsByKey.get('onPreToolUse');
255
if (preToolCommands?.length || editTrackingHooks) {
256
hooks.onPreToolUse = async (input: PreToolUseHookInput) => {
257
await editTrackingHooks?.onPreToolUse(input);
258
return runHookCommands(preToolCommands, input);
259
};
260
}
261
262
// Post-tool-use handler
263
const postToolCommands = commandsByKey.get('onPostToolUse');
264
if (postToolCommands?.length || editTrackingHooks) {
265
hooks.onPostToolUse = async (input: PostToolUseHookInput) => {
266
await editTrackingHooks?.onPostToolUse(input);
267
return runHookCommands(postToolCommands, input);
268
};
269
}
270
271
// User-prompt-submitted handler
272
const promptCommands = commandsByKey.get('onUserPromptSubmitted');
273
if (promptCommands?.length) {
274
hooks.onUserPromptSubmitted = async (input: UserPromptSubmittedHookInput) => {
275
const stdin = JSON.stringify(input);
276
for (const cmd of promptCommands) {
277
try {
278
await executeHookCommand(cmd, stdin);
279
} catch {
280
// Hook failures are non-fatal
281
}
282
}
283
};
284
}
285
286
// Session-start handler
287
const startCommands = commandsByKey.get('onSessionStart');
288
if (startCommands?.length) {
289
hooks.onSessionStart = async (input: SessionStartHookInput) => {
290
const stdin = JSON.stringify(input);
291
for (const cmd of startCommands) {
292
try {
293
await executeHookCommand(cmd, stdin);
294
} catch {
295
// Hook failures are non-fatal
296
}
297
}
298
};
299
}
300
301
// Session-end handler
302
const endCommands = commandsByKey.get('onSessionEnd');
303
if (endCommands?.length) {
304
hooks.onSessionEnd = async (input: SessionEndHookInput) => {
305
const stdin = JSON.stringify(input);
306
for (const cmd of endCommands) {
307
try {
308
await executeHookCommand(cmd, stdin);
309
} catch {
310
// Hook failures are non-fatal
311
}
312
}
313
};
314
}
315
316
// Error-occurred handler
317
const errorCommands = commandsByKey.get('onErrorOccurred');
318
if (errorCommands?.length) {
319
hooks.onErrorOccurred = async (input: ErrorOccurredHookInput) => {
320
const stdin = JSON.stringify(input);
321
for (const cmd of errorCommands) {
322
try {
323
await executeHookCommand(cmd, stdin);
324
} catch {
325
// Hook failures are non-fatal
326
}
327
}
328
};
329
}
330
331
return hooks;
332
}
333
334
/**
335
* Checks whether two sets of parsed plugins produce equivalent SDK config.
336
* Used to determine if a session needs to be refreshed.
337
*/
338
export function parsedPluginsEqual(a: readonly IParsedPlugin[], b: readonly IParsedPlugin[]): boolean {
339
// Simple structural comparison via JSON serialization.
340
// We serialize only the essential fields, replacing URIs with strings.
341
const serialize = (plugins: readonly IParsedPlugin[]) => {
342
return JSON.stringify(plugins.map(p => ({
343
hooks: p.hooks.map(h => ({ type: h.type, commands: h.commands.map(c => ({ command: c.command, windows: c.windows, linux: c.linux, osx: c.osx, cwd: c.cwd?.toString(), env: c.env, timeout: c.timeout })) })),
344
mcpServers: p.mcpServers.map(m => ({ name: m.name, configuration: m.configuration })),
345
skills: p.skills.map(s => ({ uri: s.uri.toString(), name: s.name })),
346
agents: p.agents.map(a => ({ uri: a.uri.toString(), name: a.name })),
347
})));
348
};
349
return serialize(a) === serialize(b);
350
}
351
352