@github/copilot-sdk 0.0.1 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1 @@
1
+ Coming soon
@@ -0,0 +1,95 @@
1
+ import { CopilotSession } from "./session.js";
2
+ import type { ConnectionState, CopilotClientOptions, ResumeSessionConfig, SessionConfig, SessionMetadata } from "./types.js";
3
+ export declare class CopilotClient {
4
+ private cliProcess;
5
+ private connection;
6
+ private socket;
7
+ private actualPort;
8
+ private actualHost;
9
+ private state;
10
+ private sessions;
11
+ private options;
12
+ private isExternalServer;
13
+ private forceStopping;
14
+ constructor(options?: CopilotClientOptions);
15
+ /**
16
+ * Parse CLI URL into host and port
17
+ * Supports formats: "host:port", "http://host:port", "https://host:port", or just "port"
18
+ */
19
+ private parseCliUrl;
20
+ /**
21
+ * Start the CLI server and establish connection
22
+ */
23
+ start(): Promise<void>;
24
+ /**
25
+ * Stop the CLI server and close all sessions
26
+ * Returns array of errors encountered during cleanup (empty if all succeeded)
27
+ */
28
+ stop(): Promise<Error[]>;
29
+ /**
30
+ * Force stop the CLI server without graceful cleanup
31
+ * Use when normal stop() fails or takes too long
32
+ */
33
+ forceStop(): Promise<void>;
34
+ /**
35
+ * Create a new session
36
+ */
37
+ createSession(config?: SessionConfig): Promise<CopilotSession>;
38
+ /**
39
+ * Resume an existing session
40
+ */
41
+ resumeSession(sessionId: string, config?: ResumeSessionConfig): Promise<CopilotSession>;
42
+ /**
43
+ * Get connection state
44
+ */
45
+ getState(): ConnectionState;
46
+ /**
47
+ * Ping the server
48
+ */
49
+ ping(message?: string): Promise<{
50
+ message: string;
51
+ timestamp: number;
52
+ }>;
53
+ /**
54
+ * Get the ID of the most recently updated session
55
+ * @returns The session ID, or undefined if no sessions exist
56
+ */
57
+ getLastSessionId(): Promise<string | undefined>;
58
+ /**
59
+ * Delete a session and its data from disk
60
+ * @param sessionId The ID of the session to delete
61
+ */
62
+ deleteSession(sessionId: string): Promise<void>;
63
+ /**
64
+ * List all available sessions
65
+ * @returns Array of session metadata
66
+ */
67
+ listSessions(): Promise<SessionMetadata[]>;
68
+ /**
69
+ * Start the CLI server process
70
+ */
71
+ private startCLIServer;
72
+ /**
73
+ * Connect to the CLI server (via socket or stdio)
74
+ */
75
+ private connectToServer;
76
+ /**
77
+ * Connect via stdio pipes
78
+ */
79
+ private connectViaStdio;
80
+ /**
81
+ * Connect to the CLI server via TCP socket
82
+ */
83
+ private connectViaTcp;
84
+ private attachConnectionHandlers;
85
+ private handleSessionEventNotification;
86
+ private handleToolCallRequest;
87
+ private executeToolCall;
88
+ private normalizeToolResult;
89
+ private isToolResultObject;
90
+ private buildUnsupportedToolResult;
91
+ /**
92
+ * Attempt to reconnect to the server
93
+ */
94
+ private reconnect;
95
+ }
package/dist/client.js ADDED
@@ -0,0 +1,549 @@
1
+ import { spawn } from "node:child_process";
2
+ import { Socket } from "node:net";
3
+ import {
4
+ createMessageConnection,
5
+ StreamMessageReader,
6
+ StreamMessageWriter
7
+ } from "vscode-jsonrpc/node.js";
8
+ import { CopilotSession } from "./session.js";
9
+ function isZodSchema(value) {
10
+ return value != null && typeof value === "object" && "toJSONSchema" in value && typeof value.toJSONSchema === "function";
11
+ }
12
+ function toJsonSchema(parameters) {
13
+ if (!parameters) return void 0;
14
+ if (isZodSchema(parameters)) {
15
+ return parameters.toJSONSchema();
16
+ }
17
+ return parameters;
18
+ }
19
+ class CopilotClient {
20
+ cliProcess = null;
21
+ connection = null;
22
+ socket = null;
23
+ actualPort = null;
24
+ actualHost = "localhost";
25
+ state = "disconnected";
26
+ sessions = /* @__PURE__ */ new Map();
27
+ options;
28
+ isExternalServer = false;
29
+ forceStopping = false;
30
+ constructor(options = {}) {
31
+ if (options.cliUrl && (options.useStdio === true || options.cliPath)) {
32
+ throw new Error("cliUrl is mutually exclusive with useStdio and cliPath");
33
+ }
34
+ if (options.cliUrl) {
35
+ const { host, port } = this.parseCliUrl(options.cliUrl);
36
+ this.actualHost = host;
37
+ this.actualPort = port;
38
+ this.isExternalServer = true;
39
+ }
40
+ this.options = {
41
+ cliPath: options.cliPath || "copilot",
42
+ cliArgs: options.cliArgs ?? [],
43
+ cwd: options.cwd ?? process.cwd(),
44
+ port: options.port || 0,
45
+ useStdio: options.cliUrl ? false : options.useStdio ?? true,
46
+ // Default to stdio unless cliUrl is provided
47
+ cliUrl: options.cliUrl,
48
+ logLevel: options.logLevel || "info",
49
+ autoStart: options.autoStart ?? true,
50
+ autoRestart: options.autoRestart ?? true,
51
+ env: options.env ?? process.env
52
+ };
53
+ }
54
+ /**
55
+ * Parse CLI URL into host and port
56
+ * Supports formats: "host:port", "http://host:port", "https://host:port", or just "port"
57
+ */
58
+ parseCliUrl(url) {
59
+ let cleanUrl = url.replace(/^https?:\/\//, "");
60
+ if (/^\d+$/.test(cleanUrl)) {
61
+ return { host: "localhost", port: parseInt(cleanUrl, 10) };
62
+ }
63
+ const parts = cleanUrl.split(":");
64
+ if (parts.length !== 2) {
65
+ throw new Error(
66
+ `Invalid cliUrl format: ${url}. Expected "host:port", "http://host:port", or "port"`
67
+ );
68
+ }
69
+ const host = parts[0] || "localhost";
70
+ const port = parseInt(parts[1], 10);
71
+ if (isNaN(port) || port <= 0 || port > 65535) {
72
+ throw new Error(`Invalid port in cliUrl: ${url}`);
73
+ }
74
+ return { host, port };
75
+ }
76
+ /**
77
+ * Start the CLI server and establish connection
78
+ */
79
+ async start() {
80
+ if (this.state === "connected") {
81
+ return;
82
+ }
83
+ this.state = "connecting";
84
+ try {
85
+ if (!this.isExternalServer) {
86
+ await this.startCLIServer();
87
+ }
88
+ await this.connectToServer();
89
+ this.state = "connected";
90
+ } catch (error) {
91
+ this.state = "error";
92
+ throw error;
93
+ }
94
+ }
95
+ /**
96
+ * Stop the CLI server and close all sessions
97
+ * Returns array of errors encountered during cleanup (empty if all succeeded)
98
+ */
99
+ async stop() {
100
+ const errors = [];
101
+ for (const session of this.sessions.values()) {
102
+ const sessionId = session.sessionId;
103
+ let lastError = null;
104
+ for (let attempt = 1; attempt <= 3; attempt++) {
105
+ try {
106
+ await session.destroy();
107
+ lastError = null;
108
+ break;
109
+ } catch (error) {
110
+ lastError = error instanceof Error ? error : new Error(String(error));
111
+ if (attempt < 3) {
112
+ const delay = 100 * Math.pow(2, attempt - 1);
113
+ await new Promise((resolve) => setTimeout(resolve, delay));
114
+ }
115
+ }
116
+ }
117
+ if (lastError) {
118
+ errors.push(
119
+ new Error(
120
+ `Failed to destroy session ${sessionId} after 3 attempts: ${lastError.message}`
121
+ )
122
+ );
123
+ }
124
+ }
125
+ this.sessions.clear();
126
+ if (this.connection) {
127
+ try {
128
+ this.connection.dispose();
129
+ } catch (error) {
130
+ errors.push(
131
+ new Error(
132
+ `Failed to dispose connection: ${error instanceof Error ? error.message : String(error)}`
133
+ )
134
+ );
135
+ }
136
+ this.connection = null;
137
+ }
138
+ if (this.socket) {
139
+ try {
140
+ this.socket.end();
141
+ } catch (error) {
142
+ errors.push(
143
+ new Error(
144
+ `Failed to close socket: ${error instanceof Error ? error.message : String(error)}`
145
+ )
146
+ );
147
+ }
148
+ this.socket = null;
149
+ }
150
+ if (this.cliProcess && !this.isExternalServer) {
151
+ try {
152
+ this.cliProcess.kill();
153
+ } catch (error) {
154
+ errors.push(
155
+ new Error(
156
+ `Failed to kill CLI process: ${error instanceof Error ? error.message : String(error)}`
157
+ )
158
+ );
159
+ }
160
+ this.cliProcess = null;
161
+ }
162
+ this.state = "disconnected";
163
+ this.actualPort = null;
164
+ return errors;
165
+ }
166
+ /**
167
+ * Force stop the CLI server without graceful cleanup
168
+ * Use when normal stop() fails or takes too long
169
+ */
170
+ async forceStop() {
171
+ this.forceStopping = true;
172
+ this.sessions.clear();
173
+ if (this.connection) {
174
+ try {
175
+ this.connection.dispose();
176
+ } catch {
177
+ }
178
+ this.connection = null;
179
+ }
180
+ if (this.socket) {
181
+ try {
182
+ this.socket.destroy();
183
+ } catch {
184
+ }
185
+ this.socket = null;
186
+ }
187
+ if (this.cliProcess && !this.isExternalServer) {
188
+ try {
189
+ this.cliProcess.kill("SIGKILL");
190
+ } catch {
191
+ }
192
+ this.cliProcess = null;
193
+ }
194
+ this.state = "disconnected";
195
+ this.actualPort = null;
196
+ }
197
+ /**
198
+ * Create a new session
199
+ */
200
+ async createSession(config = {}) {
201
+ if (!this.connection) {
202
+ if (this.options.autoStart) {
203
+ await this.start();
204
+ } else {
205
+ throw new Error("Client not connected. Call start() first.");
206
+ }
207
+ }
208
+ const response = await this.connection.sendRequest("session.create", {
209
+ model: config.model,
210
+ sessionId: config.sessionId,
211
+ tools: config.tools?.map((tool) => ({
212
+ name: tool.name,
213
+ description: tool.description,
214
+ parameters: toJsonSchema(tool.parameters)
215
+ })),
216
+ systemMessage: config.systemMessage,
217
+ availableTools: config.availableTools,
218
+ excludedTools: config.excludedTools,
219
+ provider: config.provider
220
+ });
221
+ const sessionId = response.sessionId;
222
+ const session = new CopilotSession(sessionId, this.connection);
223
+ session.registerTools(config.tools);
224
+ this.sessions.set(sessionId, session);
225
+ return session;
226
+ }
227
+ /**
228
+ * Resume an existing session
229
+ */
230
+ async resumeSession(sessionId, config = {}) {
231
+ if (!this.connection) {
232
+ if (this.options.autoStart) {
233
+ await this.start();
234
+ } else {
235
+ throw new Error("Client not connected. Call start() first.");
236
+ }
237
+ }
238
+ const response = await this.connection.sendRequest("session.resume", {
239
+ sessionId,
240
+ tools: config.tools?.map((tool) => ({
241
+ name: tool.name,
242
+ description: tool.description,
243
+ parameters: toJsonSchema(tool.parameters)
244
+ })),
245
+ provider: config.provider
246
+ });
247
+ const resumedSessionId = response.sessionId;
248
+ const session = new CopilotSession(resumedSessionId, this.connection);
249
+ session.registerTools(config.tools);
250
+ this.sessions.set(resumedSessionId, session);
251
+ return session;
252
+ }
253
+ /**
254
+ * Get connection state
255
+ */
256
+ getState() {
257
+ return this.state;
258
+ }
259
+ /**
260
+ * Ping the server
261
+ */
262
+ async ping(message) {
263
+ if (!this.connection) {
264
+ throw new Error("Client not connected");
265
+ }
266
+ const result = await this.connection.sendRequest("ping", { message });
267
+ return result;
268
+ }
269
+ /**
270
+ * Get the ID of the most recently updated session
271
+ * @returns The session ID, or undefined if no sessions exist
272
+ */
273
+ async getLastSessionId() {
274
+ if (!this.connection) {
275
+ throw new Error("Client not connected");
276
+ }
277
+ const response = await this.connection.sendRequest("session.getLastId", {});
278
+ return response.sessionId;
279
+ }
280
+ /**
281
+ * Delete a session and its data from disk
282
+ * @param sessionId The ID of the session to delete
283
+ */
284
+ async deleteSession(sessionId) {
285
+ if (!this.connection) {
286
+ throw new Error("Client not connected");
287
+ }
288
+ const response = await this.connection.sendRequest("session.delete", {
289
+ sessionId
290
+ });
291
+ const { success, error } = response;
292
+ if (!success) {
293
+ throw new Error(`Failed to delete session ${sessionId}: ${error || "Unknown error"}`);
294
+ }
295
+ this.sessions.delete(sessionId);
296
+ }
297
+ /**
298
+ * List all available sessions
299
+ * @returns Array of session metadata
300
+ */
301
+ async listSessions() {
302
+ if (!this.connection) {
303
+ throw new Error("Client not connected");
304
+ }
305
+ const response = await this.connection.sendRequest("session.list", {});
306
+ const { sessions } = response;
307
+ return sessions.map((s) => ({
308
+ sessionId: s.sessionId,
309
+ startTime: new Date(s.startTime),
310
+ modifiedTime: new Date(s.modifiedTime),
311
+ summary: s.summary,
312
+ isRemote: s.isRemote
313
+ }));
314
+ }
315
+ /**
316
+ * Start the CLI server process
317
+ */
318
+ async startCLIServer() {
319
+ return new Promise((resolve, reject) => {
320
+ const args = [
321
+ ...this.options.cliArgs,
322
+ "--server",
323
+ "--log-level",
324
+ this.options.logLevel
325
+ ];
326
+ if (this.options.useStdio) {
327
+ args.push("--stdio");
328
+ } else if (this.options.port > 0) {
329
+ args.push("--port", this.options.port.toString());
330
+ }
331
+ const envWithoutNodeDebug = { ...this.options.env };
332
+ delete envWithoutNodeDebug.NODE_DEBUG;
333
+ const isJsFile = this.options.cliPath.endsWith(".js");
334
+ const command = isJsFile ? "node" : this.options.cliPath;
335
+ const spawnArgs = isJsFile ? [this.options.cliPath, ...args] : args;
336
+ this.cliProcess = spawn(command, spawnArgs, {
337
+ stdio: this.options.useStdio ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"],
338
+ cwd: this.options.cwd,
339
+ env: envWithoutNodeDebug
340
+ });
341
+ let stdout = "";
342
+ let resolved = false;
343
+ if (this.options.useStdio) {
344
+ resolved = true;
345
+ resolve();
346
+ } else {
347
+ this.cliProcess.stdout?.on("data", (data) => {
348
+ stdout += data.toString();
349
+ const match = stdout.match(/listening on port (\d+)/i);
350
+ if (match && !resolved) {
351
+ this.actualPort = parseInt(match[1], 10);
352
+ resolved = true;
353
+ resolve();
354
+ }
355
+ });
356
+ }
357
+ this.cliProcess.stderr?.on("data", (data) => {
358
+ const lines = data.toString().split("\n");
359
+ for (const line of lines) {
360
+ if (line.trim()) {
361
+ process.stderr.write(`[CLI subprocess] ${line}
362
+ `);
363
+ }
364
+ }
365
+ });
366
+ this.cliProcess.on("error", (error) => {
367
+ if (!resolved) {
368
+ resolved = true;
369
+ reject(new Error(`Failed to start CLI server: ${error.message}`));
370
+ }
371
+ });
372
+ this.cliProcess.on("exit", (code) => {
373
+ if (!resolved) {
374
+ resolved = true;
375
+ reject(new Error(`CLI server exited with code ${code}`));
376
+ } else if (this.options.autoRestart && this.state === "connected") {
377
+ void this.reconnect();
378
+ }
379
+ });
380
+ setTimeout(() => {
381
+ if (!resolved) {
382
+ resolved = true;
383
+ reject(new Error("Timeout waiting for CLI server to start"));
384
+ }
385
+ }, 1e4);
386
+ });
387
+ }
388
+ /**
389
+ * Connect to the CLI server (via socket or stdio)
390
+ */
391
+ async connectToServer() {
392
+ if (this.options.useStdio) {
393
+ return this.connectViaStdio();
394
+ } else {
395
+ return this.connectViaTcp();
396
+ }
397
+ }
398
+ /**
399
+ * Connect via stdio pipes
400
+ */
401
+ async connectViaStdio() {
402
+ if (!this.cliProcess) {
403
+ throw new Error("CLI process not started");
404
+ }
405
+ this.cliProcess.stdin?.on("error", (err) => {
406
+ if (!this.forceStopping) {
407
+ throw err;
408
+ }
409
+ });
410
+ this.connection = createMessageConnection(
411
+ new StreamMessageReader(this.cliProcess.stdout),
412
+ new StreamMessageWriter(this.cliProcess.stdin)
413
+ );
414
+ this.attachConnectionHandlers();
415
+ this.connection.listen();
416
+ }
417
+ /**
418
+ * Connect to the CLI server via TCP socket
419
+ */
420
+ async connectViaTcp() {
421
+ if (!this.actualPort) {
422
+ throw new Error("Server port not available");
423
+ }
424
+ return new Promise((resolve, reject) => {
425
+ this.socket = new Socket();
426
+ this.socket.connect(this.actualPort, this.actualHost, () => {
427
+ this.connection = createMessageConnection(
428
+ new StreamMessageReader(this.socket),
429
+ new StreamMessageWriter(this.socket)
430
+ );
431
+ this.attachConnectionHandlers();
432
+ this.connection.listen();
433
+ resolve();
434
+ });
435
+ this.socket.on("error", (error) => {
436
+ reject(new Error(`Failed to connect to CLI server: ${error.message}`));
437
+ });
438
+ });
439
+ }
440
+ attachConnectionHandlers() {
441
+ if (!this.connection) {
442
+ return;
443
+ }
444
+ this.connection.onNotification("session.event", (notification) => {
445
+ this.handleSessionEventNotification(notification);
446
+ });
447
+ this.connection.onRequest(
448
+ "tool.call",
449
+ async (params) => await this.handleToolCallRequest(params)
450
+ );
451
+ this.connection.onClose(() => {
452
+ if (this.state === "connected" && this.options.autoRestart) {
453
+ void this.reconnect();
454
+ }
455
+ });
456
+ this.connection.onError((_error) => {
457
+ });
458
+ }
459
+ handleSessionEventNotification(notification) {
460
+ if (typeof notification !== "object" || !notification || !("sessionId" in notification) || typeof notification.sessionId !== "string" || !("event" in notification)) {
461
+ return;
462
+ }
463
+ const session = this.sessions.get(notification.sessionId);
464
+ if (session) {
465
+ session._dispatchEvent(notification.event);
466
+ }
467
+ }
468
+ async handleToolCallRequest(params) {
469
+ if (!params || typeof params.sessionId !== "string" || typeof params.toolCallId !== "string" || typeof params.toolName !== "string") {
470
+ throw new Error("Invalid tool call payload");
471
+ }
472
+ const session = this.sessions.get(params.sessionId);
473
+ if (!session) {
474
+ throw new Error(`Unknown session ${params.sessionId}`);
475
+ }
476
+ const handler = session.getToolHandler(params.toolName);
477
+ if (!handler) {
478
+ return { result: this.buildUnsupportedToolResult(params.toolName) };
479
+ }
480
+ return await this.executeToolCall(handler, params);
481
+ }
482
+ async executeToolCall(handler, request) {
483
+ try {
484
+ const invocation = {
485
+ sessionId: request.sessionId,
486
+ toolCallId: request.toolCallId,
487
+ toolName: request.toolName,
488
+ arguments: request.arguments
489
+ };
490
+ const result = await handler(request.arguments, invocation);
491
+ return { result: this.normalizeToolResult(result) };
492
+ } catch (error) {
493
+ const message = error instanceof Error ? error.message : String(error);
494
+ return {
495
+ result: {
496
+ // Don't expose detailed error information to the LLM for security reasons
497
+ textResultForLlm: "Invoking this tool produced an error. Detailed information is not available.",
498
+ resultType: "failure",
499
+ error: message,
500
+ toolTelemetry: {}
501
+ }
502
+ };
503
+ }
504
+ }
505
+ normalizeToolResult(result) {
506
+ if (result === void 0 || result === null) {
507
+ return {
508
+ textResultForLlm: "Tool returned no result",
509
+ resultType: "failure",
510
+ error: "tool returned no result",
511
+ toolTelemetry: {}
512
+ };
513
+ }
514
+ if (this.isToolResultObject(result)) {
515
+ return result;
516
+ }
517
+ const textResult = typeof result === "string" ? result : JSON.stringify(result);
518
+ return {
519
+ textResultForLlm: textResult,
520
+ resultType: "success",
521
+ toolTelemetry: {}
522
+ };
523
+ }
524
+ isToolResultObject(value) {
525
+ return typeof value === "object" && value !== null && "textResultForLlm" in value && typeof value.textResultForLlm === "string" && "resultType" in value;
526
+ }
527
+ buildUnsupportedToolResult(toolName) {
528
+ return {
529
+ textResultForLlm: `Tool '${toolName}' is not supported by this client instance.`,
530
+ resultType: "failure",
531
+ error: `tool '${toolName}' not supported`,
532
+ toolTelemetry: {}
533
+ };
534
+ }
535
+ /**
536
+ * Attempt to reconnect to the server
537
+ */
538
+ async reconnect() {
539
+ this.state = "disconnected";
540
+ try {
541
+ await this.stop();
542
+ await this.start();
543
+ } catch (_error) {
544
+ }
545
+ }
546
+ }
547
+ export {
548
+ CopilotClient
549
+ };