Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ on:
options:
- latest
- prerelease
- unstable
version:
description: "Version override (optional, e.g., 1.0.0). If empty, auto-increments."
type: string
Expand Down Expand Up @@ -66,8 +67,8 @@ jobs:
fi
else
if [[ "$VERSION" != *-* ]]; then
echo "❌ Error: Version '$VERSION' has no prerelease suffix but dist-tag is 'prerelease'" >> $GITHUB_STEP_SUMMARY
echo "Use a version with suffix (e.g., '1.0.0-preview.0') for prerelease"
echo "❌ Error: Version '$VERSION' has no prerelease suffix but dist-tag is '${{ github.event.inputs.dist-tag }}'" >> $GITHUB_STEP_SUMMARY
echo "Use a version with suffix (e.g., '1.0.0-preview.0') for prerelease/unstable"
exit 1
fi
fi
Expand Down Expand Up @@ -107,11 +108,12 @@ jobs:
name: nodejs-package
path: nodejs/*.tgz
- name: Publish to npm
if: github.ref == 'refs/heads/main'
if: github.ref == 'refs/heads/main' || github.event.inputs.dist-tag == 'unstable'
run: npm publish --tag ${{ github.event.inputs.dist-tag }} --access public --registry https://registry.npmjs.org

publish-dotnet:
name: Publish .NET SDK
if: github.event.inputs.dist-tag != 'unstable'
needs: version
runs-on: ubuntu-latest
defaults:
Expand Down Expand Up @@ -147,6 +149,7 @@ jobs:

publish-python:
name: Publish Python SDK
if: github.event.inputs.dist-tag != 'unstable'
needs: version
runs-on: ubuntu-latest
defaults:
Expand Down Expand Up @@ -183,7 +186,7 @@ jobs:
github-release:
name: Create GitHub Release
needs: [version, publish-nodejs, publish-dotnet, publish-python]
if: github.ref == 'refs/heads/main'
if: github.ref == 'refs/heads/main' && github.event.inputs.dist-tag != 'unstable'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
Expand Down
4 changes: 4 additions & 0 deletions nodejs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./extension": {
"import": "./dist/extension.js",
"types": "./dist/extension.d.ts"
}
},
"type": "module",
Expand Down
14 changes: 11 additions & 3 deletions nodejs/scripts/get-version.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*
* Usage:
*
* node scripts/get-version.js [current|current-prerelease|latest|prerelease]
* node scripts/get-version.js [current|current-prerelease|latest|prerelease|unstable]
*
* Outputs the version to stdout.
*/
Expand All @@ -32,7 +32,7 @@ async function getLatestVersion(tag) {

async function main() {
const command = process.argv[2];
const validCommands = ["current", "current-prerelease", "latest", "prerelease"];
const validCommands = ["current", "current-prerelease", "latest", "prerelease", "unstable"];
if (!validCommands.includes(command)) {
console.error(
`Invalid argument, must be one of: ${validCommands.join(", ")}, got: "${command}"`
Expand Down Expand Up @@ -75,8 +75,16 @@ async function main() {
return;
}

if (command === "unstable") {
const unstable = await getLatestVersion("unstable");
if (unstable && semver.gt(unstable, higherVersion)) {
higherVersion = unstable;
}
}

const increment = command === "latest" ? "patch" : "prerelease";
const prereleaseIdentifier = command === "prerelease" ? "preview" : undefined;
const prereleaseIdentifier =
command === "prerelease" ? "preview" : command === "unstable" ? "unstable" : undefined;
const nextVersion = semver.inc(higherVersion, increment, prereleaseIdentifier);
if (!nextVersion) {
console.error(`Failed to increment version "${higherVersion}" with "${increment}"`);
Expand Down
185 changes: 40 additions & 145 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,6 @@ import type {
SessionListFilter,
SessionMetadata,
Tool,
ToolCallRequestPayload,
ToolCallResponsePayload,
ToolHandler,
ToolResult,
ToolResultObject,
TypedSessionLifecycleHandler,
} from "./types.js";

Expand Down Expand Up @@ -196,6 +191,12 @@ export class CopilotClient {
throw new Error("cliUrl is mutually exclusive with useStdio and cliPath");
}

if (options.isChildProcess && (options.cliUrl || options.useStdio === false)) {
throw new Error(
"isChildProcess must be used in conjunction with useStdio and not with cliUrl"
);
}

// Validate auth options with external server
if (options.cliUrl && (options.githubToken || options.useLoggedInUser !== undefined)) {
throw new Error(
Expand All @@ -211,12 +212,17 @@ export class CopilotClient {
this.isExternalServer = true;
}

if (options.isChildProcess) {
this.isExternalServer = true;
}

this.options = {
cliPath: options.cliPath || getBundledCliPath(),
cliArgs: options.cliArgs ?? [],
cwd: options.cwd ?? process.cwd(),
port: options.port || 0,
useStdio: options.cliUrl ? false : (options.useStdio ?? true), // Default to stdio unless cliUrl is provided
isChildProcess: options.isChildProcess ?? false,
cliUrl: options.cliUrl,
logLevel: options.logLevel || "debug",
autoStart: options.autoStart ?? true,
Expand Down Expand Up @@ -1204,17 +1210,19 @@ export class CopilotClient {
* Connect to the CLI server (via socket or stdio)
*/
private async connectToServer(): Promise<void> {
if (this.options.useStdio) {
return this.connectViaStdio();
if (this.options.isChildProcess) {
return this.connectToParentProcessViaStdio();
} else if (this.options.useStdio) {
return this.connectToChildProcessViaStdio();
} else {
return this.connectViaTcp();
}
}

/**
* Connect via stdio pipes
* Connect to child via stdio pipes
*/
private async connectViaStdio(): Promise<void> {
private async connectToChildProcessViaStdio(): Promise<void> {
if (!this.cliProcess) {
throw new Error("CLI process not started");
}
Expand All @@ -1236,6 +1244,24 @@ export class CopilotClient {
this.connection.listen();
}

/**
* Connect to parent via stdio pipes
*/
private async connectToParentProcessViaStdio(): Promise<void> {
if (this.cliProcess) {
throw new Error("CLI child process was unexpectedly started in parent process mode");
}

// Create JSON-RPC connection over stdin/stdout
this.connection = createMessageConnection(
new StreamMessageReader(process.stdin),
new StreamMessageWriter(process.stdout)
);

this.attachConnectionHandlers();
this.connection.listen();
}

/**
* Connect to the CLI server via TCP socket
*/
Expand Down Expand Up @@ -1278,19 +1304,11 @@ export class CopilotClient {
this.handleSessionLifecycleNotification(notification);
});

this.connection.onRequest(
"tool.call",
async (params: ToolCallRequestPayload): Promise<ToolCallResponsePayload> =>
await this.handleToolCallRequest(params)
);

this.connection.onRequest(
"permission.request",
async (params: {
sessionId: string;
permissionRequest: unknown;
}): Promise<{ result: unknown }> => await this.handlePermissionRequest(params)
);
// External tool calls and permission requests are now handled via broadcast events:
// the server sends external_tool.requested / permission.requested as session event
// notifications, and CopilotSession._dispatchEvent handles them internally by
// executing the handler and responding via session.tools.handlePendingToolCall /
// session.permissions.handlePendingPermissionRequest RPC.

this.connection.onRequest(
"userInput.request",
Expand Down Expand Up @@ -1376,86 +1394,6 @@ export class CopilotClient {
}
}

private async handleToolCallRequest(
params: ToolCallRequestPayload
): Promise<ToolCallResponsePayload> {
if (
!params ||
typeof params.sessionId !== "string" ||
typeof params.toolCallId !== "string" ||
typeof params.toolName !== "string"
) {
throw new Error("Invalid tool call payload");
}

const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Unknown session ${params.sessionId}`);
}

const handler = session.getToolHandler(params.toolName);
if (!handler) {
return { result: this.buildUnsupportedToolResult(params.toolName) };
}

return await this.executeToolCall(handler, params);
}

private async executeToolCall(
handler: ToolHandler,
request: ToolCallRequestPayload
): Promise<ToolCallResponsePayload> {
try {
const invocation = {
sessionId: request.sessionId,
toolCallId: request.toolCallId,
toolName: request.toolName,
arguments: request.arguments,
};
const result = await handler(request.arguments, invocation);

return { result: this.normalizeToolResult(result) };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
result: {
// Don't expose detailed error information to the LLM for security reasons
textResultForLlm:
"Invoking this tool produced an error. Detailed information is not available.",
resultType: "failure",
error: message,
toolTelemetry: {},
},
};
}
}

private async handlePermissionRequest(params: {
sessionId: string;
permissionRequest: unknown;
}): Promise<{ result: unknown }> {
if (!params || typeof params.sessionId !== "string" || !params.permissionRequest) {
throw new Error("Invalid permission request payload");
}

const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}

try {
const result = await session._handlePermissionRequest(params.permissionRequest);
return { result };
} catch (_error) {
// If permission handler fails, deny the permission
return {
result: {
kind: "denied-no-approval-rule-and-could-not-request-from-user",
},
};
}
}

private async handleUserInputRequest(params: {
sessionId: string;
question: string;
Expand Down Expand Up @@ -1505,49 +1443,6 @@ export class CopilotClient {
return { output };
}

private normalizeToolResult(result: unknown): ToolResultObject {
if (result === undefined || result === null) {
return {
textResultForLlm: "Tool returned no result",
resultType: "failure",
error: "tool returned no result",
toolTelemetry: {},
};
}

// ToolResultObject passes through directly (duck-type check)
if (this.isToolResultObject(result)) {
return result;
}

// Everything else gets wrapped as a successful ToolResultObject
const textResult = typeof result === "string" ? result : JSON.stringify(result);
return {
textResultForLlm: textResult,
resultType: "success",
toolTelemetry: {},
};
}

private isToolResultObject(value: unknown): value is ToolResultObject {
return (
typeof value === "object" &&
value !== null &&
"textResultForLlm" in value &&
typeof (value as ToolResultObject).textResultForLlm === "string" &&
"resultType" in value
);
}

private buildUnsupportedToolResult(toolName: string): ToolResult {
return {
textResultForLlm: `Tool '${toolName}' is not supported by this client instance.`,
resultType: "failure",
error: `tool '${toolName}' not supported`,
toolTelemetry: {},
};
}

/**
* Attempt to reconnect to the server
*/
Expand Down
7 changes: 7 additions & 0 deletions nodejs/src/extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

import { CopilotClient } from "./client.js";

export const extension = new CopilotClient({ isChildProcess: true });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-SDK Consistency: This new extension export for child process scenarios is Node-only.

Questions:

  1. Is this functionality needed in other SDKs?
  2. Should Python, Go, and .NET provide equivalent child process integration patterns?
  3. Is this a Node-specific use case or a general SDK feature?

If this is generally useful, consider adding equivalent exports in the other SDKs.

Loading
Loading