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
45 changes: 45 additions & 0 deletions nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ new CopilotClient(options?: CopilotClientOptions)
- `telemetry?: TelemetryConfig` - OpenTelemetry configuration for the runtime process. Providing this object enables telemetry — no separate flag needed. See [Telemetry](#telemetry) below.
- `onGetTraceContext?: TraceContextProvider` - Advanced: callback for linking your application's own OpenTelemetry spans into the same distributed trace as the runtime's spans. Not needed for normal telemetry collection. See [Telemetry](#telemetry) below.
- `sessionFs?: SessionFsConfig` - Custom session filesystem provider.
- `extensionLaunchProvider?: ExtensionLaunchProvider` - Experimental, connection-global resolver for extension process launches. Registration must acknowledge contract version 1 before startup, creation, or resume completes. See [Extension launch providers](#extension-launch-providers-experimental).
- `sessionIdleTimeoutSeconds?: number` - Server-wide idle timeout for sessions in seconds. Ignored when connecting via `RuntimeConnection.forUri`.
- `enableRemoteSessions?: boolean` - Enable Mission Control remote session support. Ignored when connecting via `RuntimeConnection.forUri`.

Expand Down Expand Up @@ -178,6 +179,12 @@ Initial acquisition runs during session creation or resume. Cancellation, provid

Resume an existing session. Returns the session with `workspacePath` populated if infinite sessions were enabled.

##### `retainSession(sessionId: string): Promise<void>` _(experimental)_

Record and flush durable persistence intent by runtime session ID through the already-connected client. Unlike `session.rpc.retain()`, this does not need a returned `CopilotSession`: it can be awaited reentrantly in an extension launch provider while creation or resume is still pending. It sends the canonical `session.retain` RPC using generated bindings, without creating a turn or waiting for the pending session operation.

The client must already be started. An empty ID, disconnected client, or runtime retention failure rejects; this method never starts or reconnects implicitly. When persistence must precede package startup, propagate retention failure or deny the launch rather than returning an approved profile.

##### `ping(message?: string): Promise<{ message: string; timestamp: string }>`

Ping the server to check connectivity.
Expand Down Expand Up @@ -373,6 +380,12 @@ Get all events/messages from this session.

Disconnect the session and free resources. Session data on disk is preserved for later resumption.

##### `rpc.retain(): Promise<void>` _(experimental)_

Record explicit persistence intent for a local session and flush it before returning, even if no user or assistant turn has occurred. Await this after application approval and before an operation that may save data, such as a canvas open. The runtime records the canonical `session.retained` event; no synthetic prompt or model request is needed.

Retention is idempotent across stop and cold resume and is not undone by a later failed or cancelled operation. It does not grant permissions or prevent explicit deletion. Remote sessions and runtimes without this operation are unsupported; ordinary unused sessions remain ephemeral unless retained.

##### `capabilities: SessionCapabilities`

Host capabilities reported when the session was created or resumed. Use this to check feature support before calling capability-gated APIs.
Expand Down Expand Up @@ -500,6 +513,38 @@ Note: `assistant.message` and `assistant.reasoning` (final events) are always se

## Advanced Usage

### Extension launch providers (experimental)

An `extensionLaunchProvider` receives `{ id, name, modulePath, source, sessionId?, defaultLaunch? }` before an extension launches or reloads. It returns `{ launch: profile }` to approve a process profile, or `{}` / `{ launch: null }` to deny execution. Denial, thrown errors, rejected promises, the runtime's 15-second deadline, and shutdown never fall back to the built-in launcher.

When available, `defaultLaunch` is the runtime's unexecuted built-in Node bootstrap profile. Preserve it when approving that bootstrap. Embeddings without a built-in launcher, including standalone wrappers, may omit it. Use a runtime Node CLI entry through `RuntimeConnection.forStdio({ path })` when relying on this profile.

The following example delegates revision and session approval to an application-owned function; that function must verify the installed code, not merely recognize a path. It also makes the session durable before any package startup effects:

```typescript
const client = new CopilotClient({
connection: RuntimeConnection.forStdio({ path: runtimeNodeCliPath }),
extensionLaunchProvider: async (request) => {
if (
!request.sessionId ||
!request.defaultLaunch ||
!(await approveInstalledRevision(request))
) {
return { launch: null };
}
await client.retainSession(request.sessionId);
return { launch: request.defaultLaunch };
},
});
await client.start();
```

Package code can run before `createSession()` resolves. Do not await that pending operation or its eventual `CopilotSession` inside the resolver; use the connected client's retain-by-ID binding instead. The runtime routes this operation reentrantly and flushes retention before acknowledging it. After resume, wait for the required extension/canvas registration before opening or invoking it; the resume response is not a readiness barrier.

The SDK installs the callback before registration and requires the live response `{ contractVersion: 1 }` on every replacement connection. An older null acknowledgement or registration error rejects startup; the SDK never retries with the provider removed. Do not infer support from a CLI version string. Clients that omit this option send no registration request and preserve legacy launch behavior.

A resolver is not a package trust store, code-integrity check, snapshot mechanism, or sandbox. The application owns revision approval, session/workspace binding, and ensuring the approved code is the code executed. Runtime-managed restrictions still apply.

### Manual Server Control

```typescript
Expand Down
109 changes: 96 additions & 13 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from "vscode-jsonrpc/node.js";
import {
createServerRpc,
createSessionRpc,
createInternalServerRpc,
registerClientGlobalApiHandlers,
registerClientSessionApiHandlers,
Expand Down Expand Up @@ -60,6 +61,7 @@ import type {
ExitPlanModeRequest,
ExitPlanModeResult,
ExtensionJoinOptions,
ExtensionLaunchProvider,
ForegroundSessionInfo,
GetAuthStatusResponse,
BearerTokenProvider,
Expand Down Expand Up @@ -450,6 +452,7 @@ export class CopilotClient {
private state: "disconnected" | "connecting" | "connected" | "error" = "disconnected";
/** Shared in-flight start; concurrent callers await it instead of spawning another CLI. */
private startPromise: Promise<void> | null = null;
private startAbortController: AbortController | null = null;
private sessions: Map<string, CopilotSession> = new Map();
private stderrBuffer: string = ""; // Captures CLI stderr for error messages
/** Resolved connection mode chosen in the constructor. */
Expand Down Expand Up @@ -493,6 +496,7 @@ export class CopilotClient {
private requestHandler: CopilotRequestHandler | null = null;
private builtinPluginDirectories: string[] = [];
private onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void | Promise<void>;
private extensionLaunchProvider: ExtensionLaunchProvider | null = null;
private clientGlobalHandlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {};
private githubTokenProviders = new Map<
string,
Expand All @@ -504,7 +508,7 @@ export class CopilotClient {
* @throws Error if the client is not connected
*/
get rpc(): ReturnType<typeof createServerRpc> {
if (!this.connection) {
if (!this.connection || (this.extensionLaunchProvider && this.state !== "connected")) {
throw new Error("Client is not connected. Call start() first.");
}
if (!this._rpc) {
Expand Down Expand Up @@ -690,6 +694,7 @@ export class CopilotClient {
this.sessionFsConfig = options.sessionFs ?? null;
this.requestHandler = options.requestHandler ?? null;
this.onGitHubTelemetry = options.onGitHubTelemetry;
this.extensionLaunchProvider = options.extensionLaunchProvider ?? null;
this.setupClientGlobalHandlers();

// Connection-level env (child-process transports only) takes precedence
Expand Down Expand Up @@ -855,6 +860,12 @@ export class CopilotClient {
},
};
}
if (this.extensionLaunchProvider) {
const provider = this.extensionLaunchProvider;
handlers.extensionLaunchProvider = {
resolve: async (params) => await provider(params),
};
}
handlers.gitHubToken = {
getToken: (params) => this.acquireGitHubToken(params),
};
Expand Down Expand Up @@ -947,10 +958,17 @@ export class CopilotClient {
await this.startPromise;
} finally {
this.startPromise = null;
this.startAbortController = null;
}
}

private async doStart(): Promise<void> {
const controller = new AbortController();
this.startAbortController = controller;
if (this.connection || this.cliProcess || this.socket || this.ffiHost) {
await this.cleanupConnection();
}
controller.signal.throwIfAborted();
this.forceStopping = false;
this.connectionClosed = false;
this.processTransportError = null;
Expand All @@ -963,12 +981,29 @@ export class CopilotClient {
} else if (!this.isExternalServer) {
await this.startCLIServer();
}
controller.signal.throwIfAborted();

// Connect to the server
await this.connectToServer();
controller.signal.throwIfAborted();

// Verify protocol version compatibility
await this.verifyProtocolVersion();
controller.signal.throwIfAborted();

// A live acknowledgement is required for every connection. An older
// runtime's null response does not guarantee fail-closed resolution.
if (this.extensionLaunchProvider) {
const registration = await createServerRpc(
this.connection!
).registerExtensionLaunchProvider();
if (registration?.contractVersion !== 1) {
throw new Error(
"Extension launch provider requires runtime contractVersion 1."
);
}
controller.signal.throwIfAborted();
}

if (this.builtinPluginDirectories.length > 0) {
try {
Expand Down Expand Up @@ -998,6 +1033,7 @@ export class CopilotClient {
await this.connection!.sendRequest("llmInference.setProvider", {});
}

controller.signal.throwIfAborted();
this.state = "connected";
} catch (error) {
const startupError = this.processTransportError ?? error;
Expand Down Expand Up @@ -1032,6 +1068,10 @@ export class CopilotClient {
* ```
*/
async stop(): Promise<Error[]> {
if (this.startAbortController) {
await this.forceStop();
return [];
}
const errors: Error[] = [];

// Disconnect all active sessions with retry logic
Expand Down Expand Up @@ -1264,6 +1304,11 @@ export class CopilotClient {
* ```
*/
async forceStop(): Promise<void> {
this.startAbortController?.abort(new Error("Client stopped during startup."));
await this.cleanupConnection();
}

private async cleanupConnection(): Promise<void> {
this.forceStopping = true;

// Clear sessions immediately without trying to destroy them
Expand Down Expand Up @@ -1527,7 +1572,7 @@ export class CopilotClient {
if (config.gitHubToken !== undefined && config.gitHubTokenProvider !== undefined) {
throw new Error("gitHubToken and gitHubTokenProvider are mutually exclusive");
}
if (!this.connection) {
if (this.extensionLaunchProvider || !this.connection) {
await this.start();
}

Expand Down Expand Up @@ -1835,7 +1880,7 @@ export class CopilotClient {
if (config.gitHubToken !== undefined && config.gitHubTokenProvider !== undefined) {
throw new Error("gitHubToken and gitHubTokenProvider are mutually exclusive");
}
if (!this.connection) {
if (this.extensionLaunchProvider || !this.connection) {
await this.start();
}

Expand Down Expand Up @@ -2285,6 +2330,32 @@ export class CopilotClient {
return (response as { sessionId?: string }).sessionId;
}

/**
* Records and flushes durable persistence intent for a local session by ID.
*
* Uses the already-connected runtime directly, without waiting for a
* {@link CopilotSession} or an in-flight create/resume operation. In an
* {@link ExtensionLaunchProvider}, await this before returning an approved
* profile when persistence must precede the extension's top-level code.
*
* This does not start or reconnect the client. Retention failures propagate;
* a launch provider must not approve a launch when retention fails.
*
* @param sessionId - The runtime session ID to retain
* @throws Error if the ID is empty, the client is not connected, or retention fails
* @experimental
*/
async retainSession(sessionId: string): Promise<void> {
if (typeof sessionId !== "string" || sessionId.length === 0) {
throw new Error("sessionId must be a non-empty string.");
}
const connection = this.connection;
if (!connection || this.state !== "connected") {
throw new Error("Client is not connected. Call start() first.");
}
await createSessionRpc(connection, sessionId).retain();
}

/**
* Permanently deletes a session and all its data from disk, including
* conversation history, planning state, and artifacts.
Expand Down Expand Up @@ -2697,6 +2768,7 @@ export class CopilotClient {
});
}

const child = this.cliProcess;
let stdout = "";
let resolved = false;

Expand Down Expand Up @@ -2747,8 +2819,8 @@ export class CopilotClient {

// Set up a promise that rejects when the process exits (used to race against RPC calls)
this.processExitPromise = new Promise<never>((_, rejectProcessExit) => {
this.cliProcess!.on("exit", (code) => {
if (this.messageWriter) {
child.on("exit", (code) => {
if (this.cliProcess === child && this.messageWriter) {
this.messageWriter.suppressWriteErrors = true;
}
const stderrOutput = this.stderrBuffer.trim();
Expand Down Expand Up @@ -2923,8 +2995,9 @@ export class CopilotClient {

// Keep stdin pipe errors inside the normal JSON-RPC teardown path.
// Preserve the failure reason via the gated debug log rather than discarding it.
this.cliProcess.stdin?.on("error", (err) => {
if (this.forceStopping) {
const child = this.cliProcess;
child.stdin?.on("error", (err) => {
if (this.forceStopping || this.cliProcess !== child) {
return;
}
this.state = "error";
Expand Down Expand Up @@ -2975,24 +3048,34 @@ export class CopilotClient {
* Connect to the CLI server via TCP socket
*/
private async connectViaTcp(): Promise<void> {
if (this.connectionConfig.kind === "uri") {
const { host, port } = this.parseCliUrl(this.connectionConfig.url);
this.actualHost = host;
this.runtimePort = port;
}
if (!this.runtimePort) {
throw new Error("Server port not available");
}

return new Promise((resolve, reject) => {
this.socket = new Socket();
const socket = new Socket();
this.socket = socket;

const connectionTimeout = setTimeout(() => {
this.socket?.destroy();
socket.destroy();
reject(new Error("Timeout connecting to CLI server"));
}, 10000);

this.socket.connect(this.runtimePort!, this.actualHost, () => {
socket.once("close", () => {
clearTimeout(connectionTimeout);
reject(new Error("Connection closed while connecting to CLI server"));
});
socket.connect(this.runtimePort!, this.actualHost, () => {
clearTimeout(connectionTimeout);
// Create JSON-RPC connection
this.messageWriter = new TeardownResilientStreamMessageWriter(this.socket!);
this.messageWriter = new TeardownResilientStreamMessageWriter(socket);
this.connection = createMessageConnection(
new StreamMessageReader(this.socket!),
new StreamMessageReader(socket),
this.messageWriter
);

Expand All @@ -3001,7 +3084,7 @@ export class CopilotClient {
resolve();
});

this.socket.on("error", (error) => {
socket.on("error", (error) => {
clearTimeout(connectionTimeout);
reject(new Error(`Failed to connect to CLI server: ${error.message}`));
});
Expand Down
Loading
Loading