* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/
* Copilot CLI SDK Client - Main entry point for the Copilot SDK.
*
* This module provides the {@link CopilotClient} class, which manages the connection
* to the Copilot CLI server and provides session management capabilities.
*
* @module client
*/
import { spawn, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { isIPv6, Socket } from "node:net";
import { dirname, isAbsolute, join, resolve } from "node:path";
import {
type CancellationToken,
createMessageConnection as createRpcMessageConnection,
type DataCallback,
ErrorCodes,
type Message,
MessageConnection,
ResponseError,
StreamMessageReader,
StreamMessageWriter,
} from "vscode-jsonrpc/node.js";
import {
createServerRpc,
createInternalServerRpc,
registerClientGlobalApiHandlers,
registerClientSessionApiHandlers,
} from "./generated/rpc.js";
import type {
ConnectClientInfo,
GitHubTelemetryNotification,
GitHubTokenAcquireRequest,
GitHubTokenAcquireResult,
InstallationConfirmationRequest,
OpenCanvasInstance,
SessionUpdateOptionsParams,
TaskKind,
} from "./generated/rpc.js";
import { getSdkProtocolVersion } from "./sdkProtocolVersion.js";
import { CopilotSession } from "./session.js";
import {
AhpHost,
type AhpHostExit,
type AhpHostOptions,
type AhpSessionCreateRequest,
} from "./host.js";
import type { FfiRuntimeHost } from "./ffiRuntimeHost.js";
import { ensureRuntimeBundle } from "./runtimeArtifacts.js";
import { COPILOT_CLI_VERSION } from "./cliVersion.js";
import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js";
import { createCopilotRequestAdapter } from "./copilotRequestHandler.js";
import {
createInstallationConfirmationAdapter,
type InstallationConfirmationHandler,
} from "./installationConfirmation.js";
import type { CopilotRequestHandler } from "./copilotRequestHandler.js";
import { getTraceContext } from "./telemetry.js";
import { toJsonSchema } from "./schema.js";
import { ToolSet } from "./toolSet.js";
import type {
AutoModeSwitchRequest,
AutoModeSwitchResponse,
CopilotClientInfo,
CopilotClientMode,
CopilotClientOptions,
CustomAgentConfig,
ExitPlanModeRequest,
ExitPlanModeResult,
ExtensionLaunchProvider,
ExtensionJoinOptions,
ForegroundSessionInfo,
GetAuthStatusResponse,
BearerTokenProvider,
GitHubTokenProvider,
GetStatusResponse,
InternalRuntimeConnection,
RuntimeConnection,
LargeToolOutputConfig,
MCPServerConfig,
ModelInfo,
NamedProviderConfig,
ProviderConfig,
ResumeSessionConfig,
SectionTransformFn,
SessionConfig,
SessionConfigBase,
SystemMessageConfig,
SessionCapabilities,
SessionEvent,
SessionFsConfig,
SessionLifecycleEvent,
SessionLifecycleEventType,
SessionLifecycleHandler,
SessionListFilter,
SessionMetadata,
SystemMessageCustomizeConfig,
TelemetryConfig,
TraceContextProvider,
TranscriptRecovery,
TypedSessionLifecycleHandler,
} from "./types.js";
import { defaultJoinSessionPermissionHandler } from "./types.js";
import type { WorkflowHandle } from "./workflow.js";
interface ExtensionOrchestrationContributions {
workflows?: WorkflowHandle[];
}
* Minimum protocol version this SDK can communicate with.
* Servers reporting a version below this are rejected.
*/
const MIN_PROTOCOL_VERSION = 3;
const RUNTIME_SHUTDOWN_TIMEOUT_MS = 10_000;
const CLOUD_SESSION_CLEANUP_TIMEOUT_MS = 10_000;
type DrainingMessageConnection = MessageConnection & { drain: () => Promise<void> };
function createMessageConnection(
reader: StreamMessageReader,
writer: StreamMessageWriter
): DrainingMessageConnection {
let dispatch: DataCallback | undefined;
let finishDrain: (() => void) | undefined;
let drained: Promise<void> | undefined;
let disposed = false;
const barrier = { jsonrpc: "2.0", method: "$/sdkDrain" };
const connection = createRpcMessageConnection(
{
onError: reader.onError,
onClose: reader.onClose,
onPartialMessage: reader.onPartialMessage,
listen: (callback) => {
dispatch = callback;
return reader.listen(callback);
},
dispose: () => reader.dispose(),
},
writer,
undefined,
{
messageStrategy: {
handleMessage: (message, next) => {
if (message === barrier) {
finishDrain?.();
} else {
next(message);
}
},
},
}
);
connection.onDispose(() => {
disposed = true;
finishDrain?.();
});
return Object.assign(connection, {
drain: () => {
if (!drained) {
drained = new Promise<void>((resolve) => {
finishDrain = resolve;
if (disposed || !dispatch) {
resolve();
} else {
dispatch(barrier);
}
});
}
return drained;
},
});
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error(message)), timeoutMs);
}),
]);
} finally {
if (timeout !== undefined) {
clearTimeout(timeout);
}
}
}
async function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
if (child.exitCode != null || child.signalCode != null) {
return true;
}
return new Promise<boolean>((resolve) => {
let timeout: ReturnType<typeof setTimeout>;
let settled = false;
const onExit = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolve(true);
};
timeout = setTimeout(() => {
if (settled) {
return;
}
settled = true;
child.off("exit", onExit);
resolve(false);
}, timeoutMs);
child.once("exit", onExit);
if (child.exitCode != null || child.signalCode != null) {
onExit();
}
});
}
const DEFAULT_PROVIDER_NAME = "default";
type WireProviderConfig = Omit<ProviderConfig, "bearerTokenProvider"> & {
hasBearerTokenProvider?: boolean;
};
type WireNamedProviderConfig = Omit<NamedProviderConfig, "bearerTokenProvider"> & {
hasBearerTokenProvider?: boolean;
};
* Strips the non-serializable {@link BearerTokenProvider} callbacks from the singular
* and named provider configs before they cross the RPC boundary, replacing each
* with a `hasBearerTokenProvider: true` wire flag. The callback closes over its
* own token scope/audience, so nothing scope-related crosses the wire — the
* runtime only forwards the provider name back when it needs a token.
* Returns wire-safe provider configs alongside a map of provider name → callback
* for session-side registration.
*/
function extractBearerTokenProviders(
provider: ProviderConfig | undefined,
providers: NamedProviderConfig[] | undefined
): {
wireProvider: WireProviderConfig | undefined;
wireProviders: WireNamedProviderConfig[] | undefined;
callbacks: Map<string, BearerTokenProvider>;
} {
const callbacks = new Map<string, BearerTokenProvider>();
let wireProvider: WireProviderConfig | undefined = provider;
if (provider?.bearerTokenProvider) {
const { bearerTokenProvider, ...rest } = provider;
callbacks.set(DEFAULT_PROVIDER_NAME, bearerTokenProvider);
wireProvider = {
...rest,
hasBearerTokenProvider: true,
};
}
let wireProviders: WireNamedProviderConfig[] | undefined = providers;
if (providers?.some((p) => p.bearerTokenProvider)) {
wireProviders = providers.map((p) => {
if (!p.bearerTokenProvider) return p;
const { bearerTokenProvider, ...rest } = p;
callbacks.set(p.name, bearerTokenProvider);
return {
...rest,
hasBearerTokenProvider: true,
};
});
}
return { wireProvider, wireProviders, callbacks };
}
* Convert MCP server configs from public API format (workingDirectory) to
* wire format (cwd) expected by the runtime.
*/
function toWireMcpServers(
mcpServers: Record<string, MCPServerConfig> | undefined
): Record<string, unknown> | undefined {
if (!mcpServers) return undefined;
return Object.fromEntries(
Object.entries(mcpServers).map(([name, server]) => {
if ("workingDirectory" in server) {
const { workingDirectory, ...rest } = server;
return [name, { ...rest, cwd: workingDirectory }];
}
return [name, server];
})
);
}
* Convert custom agent configs, transforming nested mcpServers from
* public API format (workingDirectory) to wire format (cwd).
*/
function toWireCustomAgents(agents: CustomAgentConfig[] | undefined): unknown[] | undefined {
if (!agents) return undefined;
return agents.map((agent) => {
if (!agent.mcpServers) return agent;
const { mcpServers, ...rest } = agent;
return { ...rest, mcpServers: toWireMcpServers(mcpServers) };
});
}
* Map the public {@link CopilotClientInfo} onto the generated connect wire
* shape, dropping empty fields. Returns `undefined` when no field carries a
* non-empty value so the caller omits `clientInfo` from the handshake and keeps
* the runtime's default attribution.
*/
function clientInfoToWire(info: CopilotClientInfo | undefined): ConnectClientInfo | undefined {
if (info == null) return undefined;
const wire: ConnectClientInfo = {};
if (info.applicationName) wire.editorName = info.applicationName;
if (info.applicationVersion) wire.editorVersion = info.applicationVersion;
if (info.integrationName) wire.extensionName = info.integrationName;
if (info.integrationVersion) wire.extensionVersion = info.integrationVersion;
return Object.keys(wire).length > 0 ? wire : undefined;
}
* Convert a {@link LargeToolOutputConfig} from the public API shape
* (`outputDirectory`) to the wire shape (`outputDir`).
*/
function toWireLargeOutput(
config: LargeToolOutputConfig | undefined
): Record<string, unknown> | undefined {
if (!config) return undefined;
const { outputDirectory, ...rest } = config;
const wire: Record<string, unknown> = { ...rest };
if (outputDirectory !== undefined) {
wire.outputDir = outputDirectory;
}
return wire;
}
function toolFilterListToArray(value: string[] | ToolSet | undefined): string[] | undefined {
if (value === undefined) {
return undefined;
}
return value instanceof ToolSet ? value.toArray() : value;
}
* Catches misuse of `availableTools`/`excludedTools` at the SDK boundary so
* users get an actionable error rather than a silently-empty filter.
*
* The runtime treats a bare `"*"` as a literal name match for a tool whose
* name is the single character `*`, which the runtime's charset guard would
* reject at registration — so the filter effectively matches nothing. We
* surface that here as an error pointing the developer at the source-qualified
* forms produced by {@link ToolSet}.
*/
function validateToolFilterList(field: string, list: string[] | undefined): void {
if (!list) {
return;
}
for (const entry of list) {
if (entry === "*") {
throw new Error(
`Invalid ${field} entry '*': there is no bare wildcard. ` +
"Use one or more of `new ToolSet().addBuiltIn('*')`, `.addMcp('*')`, " +
"or `.addCustom('*')` to target a specific source."
);
}
}
}
* Extract transform callbacks from a system message config and prepare the wire payload.
* Function-valued actions are replaced with `{ action: "transform" }` for serialization,
* and the original callbacks are returned in a separate map.
*/
function extractTransformCallbacks(systemMessage: SessionConfig["systemMessage"]): {
wirePayload: SessionConfig["systemMessage"];
transformCallbacks: Map<string, SectionTransformFn> | undefined;
} {
if (!systemMessage || systemMessage.mode !== "customize" || !systemMessage.sections) {
return { wirePayload: systemMessage, transformCallbacks: undefined };
}
const transformCallbacks = new Map<string, SectionTransformFn>();
const wireSections: Record<string, { action: string; content?: string }> = {};
for (const [sectionId, override] of Object.entries(systemMessage.sections)) {
if (!override) continue;
if (typeof override.action === "function") {
transformCallbacks.set(sectionId, override.action);
wireSections[sectionId] = { action: "transform" };
} else {
wireSections[sectionId] = { action: override.action, content: override.content };
}
}
if (transformCallbacks.size === 0) {
return { wirePayload: systemMessage, transformCallbacks: undefined };
}
const wirePayload: SystemMessageCustomizeConfig = {
...systemMessage,
sections: wireSections as SystemMessageCustomizeConfig["sections"],
};
return { wirePayload, transformCallbacks };
}
function getNodeExecPath(): string {
if (process.versions.bun) {
return "node";
}
return process.execPath;
}
function getBundledRuntimePath(): Promise<string> {
return ensureRuntimeBundle(COPILOT_CLI_VERSION);
}
* Main client for interacting with the Copilot CLI.
*
* The CopilotClient manages the connection to the Copilot CLI server and provides
* methods to create and manage conversation sessions. It can either spawn a CLI
* server process or connect to an existing server.
*
* @example
* ```typescript
* import { CopilotClient } from "@github/copilot-sdk";
*
* // Create a client with default options (spawns CLI server)
* const client = new CopilotClient();
*
* // Or connect to an existing server
* const client = new CopilotClient({ connection: RuntimeConnection.forUri("localhost:3000") });
*
* // Create a session
* const session = await client.createSession({ onPermissionRequest: approveAll, model: "gpt-4" });
*
* // Send messages and handle responses
* session.on((event) => {
* if (event.type === "assistant.message") {
* console.log(event.data.content);
* }
* });
* await session.send({ prompt: "Hello!" });
*
* // Clean up
* await session.disconnect();
* await client.stop();
* ```
*/
* A {@link StreamMessageWriter} that suppresses write failures while the client
* is tearing down its transport.
*
* During `stop()`/`forceStop()`, transport EOF, or a stdin pipe error, the runtime's
* end of the pipe can close while vscode-jsonrpc still has an in-flight write — most commonly the
* auto-generated response to a server→client request (tool/hook/userInput/LLM
* inference handler) that resolved just before teardown. That write rejects
* with `ERR_STREAM_DESTROYED`, and because the response write is internal to
* vscode-jsonrpc and awaited by nobody, the rejection surfaces as an unhandled
* rejection. The writer still fires its `error` event (forwarded to
* {@link MessageConnection.onError}), so swallowing the rejected promise during
* teardown loses no signal. Outside teardown the flag stays `false`, so write
* failures propagate normally and in-flight requests still fail fast.
*/
class TeardownResilientStreamMessageWriter extends StreamMessageWriter {
public suppressWriteErrors = false;
public override async write(msg: Message): Promise<void> {
try {
await super.write(msg);
} catch (error) {
if (!this.suppressWriteErrors) {
throw error;
}
}
}
}
export class CopilotClient {
private cliStartTimeout: ReturnType<typeof setTimeout> | null = null;
private cliProcess: ChildProcess | null = null;
private ffiHost: FfiRuntimeHost | null = null;
private connection: DrainingMessageConnection | null = null;
private requestAdapter: ReturnType<typeof createCopilotRequestAdapter> | null = null;
private messageWriter: TeardownResilientStreamMessageWriter | null = null;
private connectionClosed: boolean = false;
private connectionEpoch = 0;
private socket: Socket | null = null;
private runtimePort: number | null = null;
private actualHost: string = "localhost";
private state: "disconnected" | "connecting" | "connected" | "error" = "disconnected";
private startPromise: Promise<void> | null = null;
private sessions: Map<string, CopilotSession> = new Map();
private hostExitCallbacks = new Map<string, (exit: AhpHostExit) => void>();
private hostSessionFactories = new Map<
string,
Pick<AhpHostOptions, "createSession" | "resumeSession" | "onSessionReleased"> & {
policy: { mode: "github" | "none" | "custom"; requireIdentity?: string };
}
>();
private hostAuthenticators = new Map<
string,
Extract<AhpHostOptions["authentication"], { mode: "custom" }>
>();
private hostAuthentications = new Map<
string,
{ hostId: string; controller: AbortController }
>();
private hostHandoffs = new Map<
string,
{
hostId: string;
requestedSessionId?: string;
controller: AbortController;
configs: Map<CopilotSession, SessionConfig>;
session?: CopilotSession;
released: boolean;
onReleased?: AhpHostOptions["onSessionReleased"];
}
>();
private stderrBuffer: string = "";
private connectionConfig: InternalRuntimeConnection;
private resolvedCliPath: string | undefined;
private resolvedEnv: Record<string, string | undefined>;
private options: {
entra?: CopilotClientOptions["entra"];
workingDirectory: string;
logLevel?: string;
gitHubToken?: string;
useLoggedInUser: boolean;
telemetry?: TelemetryConfig;
baseDirectory?: string;
sessionIdleTimeoutSeconds: number;
mode: CopilotClientMode;
clientInfo?: CopilotClientInfo;
};
private isExternalServer: boolean = false;
private forceStopping: boolean = false;
private effectiveConnectionToken?: string;
private onListModels?: () => Promise<ModelInfo[]> | ModelInfo[];
private onGetTraceContext?: TraceContextProvider;
private modelsCache: ModelInfo[] | null = null;
private modelsCacheLock: Promise<void> = Promise.resolve();
private sessionLifecycleHandlers: Set<SessionLifecycleHandler> = new Set();
private typedLifecycleHandlers: Map<
SessionLifecycleEventType,
Set<(event: SessionLifecycleEvent) => void>
> = new Map();
private _rpc: ReturnType<typeof createServerRpc> | null = null;
private _internalRpc: ReturnType<typeof createInternalServerRpc> | null = null;
private processExitPromise: Promise<never> | null = null;
private processTransportError: Error | null = null;
private negotiatedProtocolVersion: number | null = null;
private sessionFsConfig: SessionFsConfig | null = null;
private requestHandler: CopilotRequestHandler | null = null;
private extensionLaunchProvider?: ExtensionLaunchProvider;
private installationConfirmationHandler?: InstallationConfirmationHandler;
private builtinPluginDirectories: string[] = [];
private onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void | Promise<void>;
private clientGlobalHandlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {};
private githubTokenProviders = new Map<
string,
{ provider: GitHubTokenProvider; sessionId?: string; committed: boolean }
>();
* Typed server-scoped RPC methods.
* @throws Error if the client is not connected
*/
get rpc(): ReturnType<typeof createServerRpc> {
if (!this.connection) {
throw new Error("Client is not connected. Call start() first.");
}
if (!this._rpc) {
this._rpc = createServerRpc(this.connection);
}
return this._rpc;
}
* Internal RPC surface (e.g. handshake helpers). Not part of the public API.
* @internal
*/
private get internalRpc(): ReturnType<typeof createInternalServerRpc> {
if (!this.connection) {
throw new Error("Client is not connected. Call start() first.");
}
if (!this._internalRpc) {
this._internalRpc = createInternalServerRpc(this.connection);
}
return this._internalRpc;
}
private logDebugTiming(message: string, startMs: number): void {
const level = this.options.logLevel?.toLowerCase();
if (level === "debug" || level === "all") {
process.stderr.write(`[copilot-sdk] ${message}. Elapsed=${Date.now() - startMs}ms\n`);
}
}
private logDebug(message: string): void {
const level = this.options.logLevel?.toLowerCase();
if (level === "debug" || level === "all") {
process.stderr.write(`[copilot-sdk] ${message}\n`);
}
}
* Environment variable that overrides the transport when the caller does not set
* {@link CopilotClientOptions.connection}. Accepts `"inprocess"` or `"stdio"`
* (case-insensitive); unset preserves the default stdio transport. Any other value
* is an error.
*/
private static readonly DEFAULT_CONNECTION_ENV_VAR = "COPILOT_SDK_DEFAULT_CONNECTION";
* Resolves the default {@link RuntimeConnection} for the no-connection case,
* honoring {@link CopilotClient.DEFAULT_CONNECTION_ENV_VAR}.
*/
private static resolveDefaultConnection(): RuntimeConnection {
const value = process.env[CopilotClient.DEFAULT_CONNECTION_ENV_VAR];
if (!value || value.toLowerCase() === "stdio") {
return { kind: "stdio" };
}
if (value.toLowerCase() === "inprocess") {
return { kind: "inprocess" };
}
throw new Error(
`Invalid ${CopilotClient.DEFAULT_CONNECTION_ENV_VAR} value '${value}'. ` +
`Expected 'inprocess', 'stdio', or unset.`
);
}
* Creates a new CopilotClient instance.
*
* @param options - Configuration options for the client
*
* @example
* ```typescript
* // Default: spawns the bundled runtime over stdio
* const client = new CopilotClient();
*
* // Connect to an existing runtime
* const client = new CopilotClient({
* connection: RuntimeConnection.forUri("localhost:3000"),
* });
*
* // Spawn the runtime over TCP on a chosen port
* const client = new CopilotClient({
* connection: RuntimeConnection.forTcp({ port: 9001 }),
* });
*
* // Use a custom runtime binary
* const client = new CopilotClient({
* connection: RuntimeConnection.forStdio({ path: "/usr/local/bin/copilot" }),
* logLevel: "debug",
* });
* ```
*/
constructor(options: CopilotClientOptions = {}) {
const conn: InternalRuntimeConnection =
options._internalConnection ??
options.connection ??
CopilotClient.resolveDefaultConnection();
if (
conn.kind === "uri" &&
(options.gitHubToken !== undefined || options.useLoggedInUser !== undefined)
) {
throw new Error(
"gitHubToken and useLoggedInUser cannot be used with RuntimeConnection.forUri (external server manages its own auth)"
);
}
if (conn.kind === "inprocess" && options.workingDirectory !== undefined) {
throw new Error(
"workingDirectory is not supported with RuntimeConnection.forInProcess(): the in-process " +
"transport hosts the runtime in this process, so honoring it would require mutating the " +
"shared process-global cwd. Change the host process's working directory before " +
"constructing the client instead."
);
}
if (conn.kind === "inprocess" && options.env !== undefined) {
throw new Error(
"env is not supported with RuntimeConnection.forInProcess(): the in-process transport loads " +
"the native runtime into the shared host process, whose single environment block cannot " +
"carry per-client values. Set the variables on the host process environment instead."
);
}
if (conn.kind === "inprocess" && options.telemetry !== undefined) {
throw new Error(
"telemetry is not supported with RuntimeConnection.forInProcess(): telemetry configuration " +
"is lowered to environment variables read by native runtime code running in the shared " +
"host process, so per-client telemetry cannot be honored in-process. Configure telemetry " +
"via the host process environment, or use a child-process transport."
);
}
if (
(conn.kind === "stdio" || conn.kind === "tcp") &&
conn.env !== undefined &&
options.env !== undefined
) {
throw new Error(
"Set environment variables via either the client-level env option or the connection's env " +
"(RuntimeConnection.forStdio/forTcp), not both. Prefer the connection-level env for " +
"child-process transports."
);
}
if (conn.kind === "tcp" && conn.connectionToken !== undefined) {
if (typeof conn.connectionToken !== "string" || conn.connectionToken.length === 0) {
throw new Error("connectionToken must be a non-empty string");
}
}
this.connectionConfig = conn;
if (options.sessionFs) {
this.validateSessionFsConfig(options.sessionFs);
}
if (options.builtinPluginDirectories) {
for (const path of options.builtinPluginDirectories) {
if (!isAbsolute(path)) {
throw new Error(
`builtinPluginDirectories must contain only absolute paths: ${path}`
);
}
}
this.builtinPluginDirectories = [...options.builtinPluginDirectories];
}
if (conn.kind === "uri") {
const { host, port } = this.parseCliUrl(conn.url);
this.actualHost = host;
this.runtimePort = port;
this.isExternalServer = true;
} else if (conn.kind === "parent-process") {
this.isExternalServer = true;
}
if (conn.kind === "tcp") {
this.effectiveConnectionToken = conn.connectionToken ?? randomUUID();
} else if (conn.kind === "uri") {
this.effectiveConnectionToken = conn.connectionToken;
}
this.onListModels = options.onListModels;
this.onGetTraceContext = options.onGetTraceContext;
this.sessionFsConfig = options.sessionFs ?? null;
this.requestHandler = options.requestHandler ?? null;
this.extensionLaunchProvider = options.extensionLaunchProvider;
this.installationConfirmationHandler = options.installationConfirmationHandler;
this.onGitHubTelemetry = options.onGitHubTelemetry;
this.setupClientGlobalHandlers();
const connEnv: Record<string, string> | undefined =
conn.kind === "stdio" || conn.kind === "tcp" ? conn.env : undefined;
const effectiveEnv = connEnv ?? options.env ?? process.env;
this.resolvedEnv = effectiveEnv;
if (conn.kind === "stdio" || conn.kind === "tcp") {
const explicitCliPath = conn.path ?? effectiveEnv.COPILOT_CLI_PATH;
if (explicitCliPath) {
this.resolvedCliPath = explicitCliPath;
}
}
const connArgs: readonly string[] =
conn.kind === "stdio" || conn.kind === "tcp" ? (conn.args ?? []) : [];
this.connectionExtraArgs = [...connArgs];
this.options = {
entra: options.entra ? { ...options.entra } : undefined,
workingDirectory: options.workingDirectory ?? process.cwd(),
logLevel: options.logLevel,
gitHubToken: options.gitHubToken,
useLoggedInUser: options.useLoggedInUser ?? (options.gitHubToken ? false : true),
telemetry: options.telemetry,
baseDirectory: options.baseDirectory,
sessionIdleTimeoutSeconds: options.sessionIdleTimeoutSeconds ?? 0,
mode: options.mode ?? "copilot-cli",
clientInfo: options.clientInfo,
};
if (this.options.mode === "empty") {
const hasPersistence =
this.options.baseDirectory !== undefined ||
this.sessionFsConfig !== null ||
conn.kind === "uri" ||
conn.kind === "parent-process";
if (!hasPersistence) {
throw new Error(
"CopilotClient was created with mode: 'empty' but neither " +
"'baseDirectory' nor 'sessionFs' was set. Empty mode requires " +
"an explicit per-session persistence location; pick one."
);
}
}
}
private connectionExtraArgs: string[] = [];
* Parse CLI URL into host and port
* Supports formats: "host:port", "[ipv6]:port", "http://host:port", "https://host:port", or just "port"
*/
private parseCliUrl(url: string): { host: string; port: number } {
const cleanUrl = url.replace(/^https?:\/\//, "");
if (/^\d+$/.test(cleanUrl)) {
return { host: "localhost", port: parseInt(cleanUrl, 10) };
}
const ipv6Match = cleanUrl.match(/^\[([^\]]+)\]:(\d+)$/);
if (ipv6Match) {
const host = ipv6Match[1];
if (!isIPv6(host)) {
throw new Error(`Invalid cliUrl format: ${url}`);
}
const port = parseInt(ipv6Match[2], 10);
if (isNaN(port) || port <= 0 || port > 65535) {
throw new Error(`Invalid port in cliUrl: ${url}`);
}
return { host, port };
}
const parts = cleanUrl.split(":");
if (parts.length !== 2) {
throw new Error(
`Invalid cliUrl format: ${url}. Expected "host:port", "[ipv6]:port", "http://host:port", or "port"`
);
}
const host = parts[0] || "localhost";
const port = parseInt(parts[1], 10);
if (isNaN(port) || port <= 0 || port > 65535) {
throw new Error(`Invalid port in cliUrl: ${url}`);
}
return { host, port };
}
private validateSessionFsConfig(config: SessionFsConfig): void {
if (!config.initialCwd) {
throw new Error("sessionFs.initialCwd is required");
}
if (!config.sessionStatePath) {
throw new Error("sessionFs.sessionStatePath is required");
}
if (config.conventions !== "windows" && config.conventions !== "posix") {
throw new Error("sessionFs.conventions must be either 'windows' or 'posix'");
}
}
private setupSessionFs(
session: CopilotSession,
config: { createSessionFsProvider?: (session: CopilotSession) => SessionFsProvider }
): void {
if (!this.sessionFsConfig) {
return;
}
if (!config.createSessionFsProvider) {
throw new Error(
"createSessionFsProvider is required in session config when sessionFs is enabled in client options."
);
}
const provider = config.createSessionFsProvider(session);
if (this.sessionFsConfig.capabilities?.sqlite && !provider.sqlite) {
throw new Error(
"SessionFsConfig declares capabilities.sqlite but the provider does not implement sqlite."
);
}
if (
this.sessionFsConfig.capabilities?.binary &&
(!provider.readFileBytes || !provider.writeFileBytes)
) {
throw new Error(
"SessionFsConfig declares capabilities.binary but the provider does not implement readFileBytes and writeFileBytes."
);
}
session.clientSessionApis.sessionFs = createSessionFsAdapter(provider);
}
private setupClientGlobalHandlers(): void {
const handlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {};
handlers.host = {
exited: async (exit) => {
this.handleHostExit(exit);
},
};
handlers.extensionLaunchProvider = this.extensionLaunchProvider;
if (this.requestHandler) {
this.requestAdapter = createCopilotRequestAdapter(this.requestHandler, () => {
if (!this.connection) {
return undefined;
}
this._rpc ??= createServerRpc(this.connection);
return this._rpc;
});
handlers.llmInference = this.requestAdapter;
}
if (this.onGitHubTelemetry) {
const onGitHubTelemetry = this.onGitHubTelemetry;
handlers.gitHubTelemetry = {
event: async (notification) => {
try {
await onGitHubTelemetry(notification);
} catch {
}
},
};
}
handlers.gitHubToken = {
getToken: (params) => this.acquireGitHubToken(params),
};
handlers.uiThread = {
run: async ({ actionId }) => {
const connection = this.connectionConfig;
const host = this.ffiHost;
if (connection.kind !== "inprocess" || !connection.runOnUiThread || !host) {
throw new Error("No in-process runOnUiThread handler is configured.");
}
await connection.runOnUiThread(() => host.runUiAction(actionId));
},
};
this.clientGlobalHandlers = handlers;
}
private async acquireGitHubToken(
params: GitHubTokenAcquireRequest
): Promise<GitHubTokenAcquireResult> {
const registration = this.githubTokenProviders.get(params.registrationId);
if (!registration) {
throw new Error(
`No GitHub token provider registered for registration ID "${params.registrationId}"`
);
}
return await registration.provider({
host: params.host,
sessionId: params.sessionId ?? registration.sessionId,
reason: params.reason,
});
}
private registerGitHubTokenProvider(
provider: GitHubTokenProvider | undefined,
sessionId?: string
): string | undefined {
if (!provider) {
return undefined;
}
const registrationId = randomUUID();
this.githubTokenProviders.set(registrationId, { provider, sessionId, committed: false });
return registrationId;
}
private assignGitHubTokenProvider(registrationId: string | undefined, sessionId: string): void {
if (!registrationId) {
return;
}
const registration = this.githubTokenProviders.get(registrationId);
if (registration) {
registration.sessionId = sessionId;
}
}
private commitGitHubTokenProvider(sessionId: string, registrationId?: string): void {
for (const [candidateId, registration] of this.githubTokenProviders) {
if (registration.sessionId === sessionId && registration.committed) {
this.githubTokenProviders.delete(candidateId);
}
}
const registration = registrationId
? this.githubTokenProviders.get(registrationId)
: undefined;
if (registration) {
registration.sessionId = sessionId;
registration.committed = true;
}
}
* Starts the CLI server and establishes a connection.
*
* If connecting to an external server (via cliUrl), only establishes the connection.
* Otherwise, spawns the CLI server process and then connects.
*
* This method is called automatically the first time you create or resume a session.
*
* @returns A promise that resolves when the connection is established
* @throws Error if the server fails to start or the connection fails
*
* @example
* ```typescript
* const client = new CopilotClient();
* await client.start();
* // Now ready to create sessions
* ```
*/
async start(): Promise<void> {
if (this.state === "connected") {
return;
}
if (this.startPromise) {
return this.startPromise;
}
this.startPromise = this.doStart();
try {
await this.startPromise;
} finally {
this.startPromise = null;
}
}
private async doStart(): Promise<void> {
this.forceStopping = false;
this.connectionEpoch++;
this.connectionClosed = false;
this.processTransportError = null;
this.state = "connecting";
try {
if (this.connectionConfig.kind === "inprocess") {
await this.startInProcessFfi();
} else if (!this.isExternalServer) {
await this.startCLIServer();
}
await this.connectToServer();
await this.verifyProtocolVersion();
if (this.extensionLaunchProvider) {
await this.rpc.registerExtensionLaunchProvider();
}
if (this.builtinPluginDirectories.length > 0) {
try {
await this.connection!.sendRequest("plugins.builtin.set", {
paths: this.builtinPluginDirectories,
});
} catch (error) {
await this.forceStop();
throw error;
}
}
if (this.sessionFsConfig) {
await this.connection!.sendRequest("sessionFs.setProvider", {
initialCwd: this.sessionFsConfig.initialCwd,
sessionStatePath: this.sessionFsConfig.sessionStatePath,
conventions: this.sessionFsConfig.conventions,
capabilities: this.sessionFsConfig.capabilities,
});
}
if (this.requestHandler) {
await this.connection!.sendRequest("llmInference.setProvider", {});
}
if (this.connectionClosed) {
throw new Error("CLI server connection closed during startup");
}
this.state = "connected";
} catch (error) {
const startupError = this.processTransportError ?? error;
await this.forceStop();
this.state = "error";
throw startupError;
}
}
* Stops the CLI server and closes all active sessions.
*
* This method performs graceful cleanup:
* 1. Closes all active sessions (releases in-memory resources)
* 2. Requests runtime shutdown for SDK-owned CLI processes
* 3. Closes the JSON-RPC connection
* 4. Signals EOF to an owned stdio process and waits for host cleanup, then
* terminates the process if it does not exit within the shutdown timeout
*
* Note: session data on disk is preserved, so sessions can be resumed later.
* To permanently remove session data before stopping, call
* {@link deleteSession} for each session first.
*
* @returns A promise that resolves with an array of errors encountered during cleanup.
* An empty array indicates all cleanup succeeded.
*
* @example
* ```typescript
* const errors = await client.stop();
* if (errors.length > 0) {
* console.error("Cleanup errors:", errors);
* }
* ```
*/
async stop(): Promise<Error[]> {
const errors: Error[] = [];
const activeSessions = [...this.sessions.values()];
if (this.connectionConfig.kind === "inprocess") {
await Promise.allSettled(activeSessions.map((session) => session.abort()));
}
for (const session of activeSessions) {
const sessionId = session.sessionId;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= 3; attempt++) {
if (this.connectionClosed) {
lastError = null;
break;
}
try {
await session.disconnect();
lastError = null;
break;
} catch (error) {
if (this.connectionClosed) {
lastError = null;
break;
}
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt < 3) {
const delay = 100 * Math.pow(2, attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
if (lastError) {
errors.push(
new Error(
`Failed to disconnect session ${sessionId} after 3 attempts: ${lastError.message}`
)
);
}
}
for (const session of activeSessions) {
session._markDisconnected();
}
this.sessions.clear();
this.githubTokenProviders.clear();
this.requestAdapter?.cancelPending();
if (
this.connection &&
!this.connectionClosed &&
(this.cliProcess || this.ffiHost) &&
!this.isExternalServer
) {
const runtimeShutdownStart = Date.now();
const shutdownPromise = this.rpc.runtime.shutdown();
void shutdownPromise.catch(() => undefined);
try {
await withTimeout(
shutdownPromise,
RUNTIME_SHUTDOWN_TIMEOUT_MS,
`runtime.shutdown timed out after ${RUNTIME_SHUTDOWN_TIMEOUT_MS}ms`
);
this.logDebugTiming(
"CopilotClient.stop runtime shutdown complete",
runtimeShutdownStart
);
} catch (error) {
this.logDebugTiming(
"CopilotClient.stop runtime shutdown failed",
runtimeShutdownStart
);
errors.push(
new Error(
`Failed to gracefully shut down runtime: ${error instanceof Error ? error.message : String(error)}`
)
);
}
}
if (this.messageWriter) {
this.messageWriter.suppressWriteErrors = true;
}
if (this.connection) {
try {
this.connection.dispose();
} catch (error) {
errors.push(
new Error(
`Failed to dispose connection: ${error instanceof Error ? error.message : String(error)}`
)
);
}
this.connection = null;
this.messageWriter = null;
this._rpc = null;
this._internalRpc = null;
}
this.modelsCache = null;
if (this.socket) {
const socket = this.socket;
this.socket = null;
try {
if (!socket.destroyed) {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await new Promise<void>((resolve) => {
socket.once("close", () => resolve());
if (this.connectionClosed) {
socket.destroy();
} else {
timeout = setTimeout(
() => socket.destroy(),
RUNTIME_SHUTDOWN_TIMEOUT_MS
);
socket.end();
}
});
} finally {
if (timeout !== undefined) clearTimeout(timeout);
}
}
} catch (error) {
errors.push(
new Error(
`Failed to close socket: ${error instanceof Error ? error.message : String(error)}`
)
);
}
}
if (this.cliProcess && !this.isExternalServer) {
const child = this.cliProcess;
if (
this.connectionConfig.kind === "stdio" &&
child.stdin &&
child.exitCode == null &&
child.signalCode == null
) {
const gracefulExitStart = Date.now();
try {
child.stdin.end();
const exited = await waitForChildExit(child, RUNTIME_SHUTDOWN_TIMEOUT_MS);
this.logDebugTiming(
exited
? "CopilotClient.stop graceful stdio exit complete"
: "CopilotClient.stop graceful stdio exit timed out; terminating child",
gracefulExitStart
);
} catch (error) {
errors.push(
new Error(
`Failed to close CLI stdin: ${error instanceof Error ? error.message : String(error)}`
)
);
}
}
try {
if (child.exitCode == null && child.signalCode == null) {
child.kill();
if (!(await waitForChildExit(child, RUNTIME_SHUTDOWN_TIMEOUT_MS))) {
errors.push(
new Error(
`Timed out waiting for CLI process to exit after kill: ${RUNTIME_SHUTDOWN_TIMEOUT_MS}ms`
)
);
}
}
} catch (error) {
errors.push(
new Error(
`Failed to kill CLI process: ${error instanceof Error ? error.message : String(error)}`
)
);
} finally {
if (this.cliProcess === child) {
this.cliProcess = null;
}
}
}
if (this.ffiHost) {
const host = this.ffiHost;
this.ffiHost = null;
try {
await host.dispose();
} catch (error) {
errors.push(
new Error(
`Failed to dispose in-process runtime host: ${error instanceof Error ? error.message : String(error)}`
)
);
}
}
if (this.cliStartTimeout) {
clearTimeout(this.cliStartTimeout);
this.cliStartTimeout = null;
}
this.state = "disconnected";
this.runtimePort = null;
this.stderrBuffer = "";
this.processExitPromise = null;
this.disconnectHosts();
return errors;
}
* Alias for {@link stop} that lets `CopilotClient` participate in `await using`
* blocks for automatic cleanup.
*
* @example
* ```typescript
* await using client = new CopilotClient();
* const session = await client.createSession({ onPermissionRequest: approveAll });
* await session.sendAndWait("Hello");
* // client.stop() is called automatically when the block exits.
* ```
*/
async [Symbol.asyncDispose](): Promise<void> {
await this.stop();
}
* Forcefully stops the CLI server without graceful cleanup.
*
* Use this when {@link stop} fails or takes too long. This method:
* - Clears all sessions immediately without destroying them
* - Force closes the connection
* - Sends SIGKILL to the CLI process (if spawned by this client)
* - Waits for the spawned CLI process to exit
*
* @returns A promise that resolves when the force stop is complete
* @throws If the spawned CLI process cannot be terminated
*
* @example
* ```typescript
* // If normal stop hangs, force stop
* const stopPromise = client.stop();
* const timeout = new Promise((_, reject) =>
* setTimeout(() => reject(new Error("Timeout")), 5000)
* );
*
* try {
* await Promise.race([stopPromise, timeout]);
* } catch {
* await client.forceStop();
* }
* ```
*/
async forceStop(): Promise<void> {
this.forceStopping = true;
this.disconnectHosts();
for (const session of this.sessions.values()) {
session._markDisconnected();
}
this.sessions.clear();
this.githubTokenProviders.clear();
this.requestAdapter?.cancelPending();
if (this.messageWriter) {
this.messageWriter.suppressWriteErrors = true;
}
if (this.connection) {
try {
this.connection.dispose();
} catch {
}
this.connection = null;
this.messageWriter = null;
this._rpc = null;
this._internalRpc = null;
}
this.modelsCache = null;
if (this.socket) {
try {
this.socket.destroy();
} catch {
}
this.socket = null;
}
if (this.ffiHost) {
const host = this.ffiHost;
this.ffiHost = null;
try {
await host.dispose();
} catch {
}
}
if (this.cliStartTimeout) {
clearTimeout(this.cliStartTimeout);
this.cliStartTimeout = null;
}
this.state = "disconnected";
this.runtimePort = null;
this.stderrBuffer = "";
this.processExitPromise = null;
if (this.cliProcess && !this.isExternalServer) {
const child = this.cliProcess;
if (child.exitCode == null && child.signalCode == null) {
child.kill("SIGKILL");
if (!(await waitForChildExit(child, RUNTIME_SHUTDOWN_TIMEOUT_MS))) {
throw new Error(
`Timed out waiting for CLI process to exit after SIGKILL: ${RUNTIME_SHUTDOWN_TIMEOUT_MS}ms`
);
}
}
if (this.cliProcess === child) {
this.cliProcess = null;
}
}
}
* Creates a new conversation session with the Copilot CLI.
*
* Sessions maintain conversation state, handle events, and manage tool execution.
* If the client is not connected, this method automatically starts the connection.
*
* @param config - Optional configuration for the session
* @returns A promise that resolves with the created session
* @throws Error if the client fails to start
*
* @example
* ```typescript
* // Basic session
* const session = await client.createSession({ onPermissionRequest: approveAll });
*
* // Session with model and tools
* const session = await client.createSession({
* onPermissionRequest: approveAll,
* model: "gpt-4",
* tools: [{
* name: "get_weather",
* description: "Get weather for a location",
* parameters: { type: "object", properties: { location: { type: "string" } } },
* handler: async (args) => ({ temperature: 72 })
* }]
* });
* ```
*/
* Normalizes session-level tool filter options. Converts {@link ToolSet}
* instances to plain string arrays, rejects misuse (bare `"*"`) and the
* missing-availableTools case in `mode = "empty"`.
*
* The SDK always sends `toolFilterPrecedence: "excluded"` so callers can
* compose include + exclude lists naturally (e.g. "everything matching X
* except Y") regardless of mode. Allowlist-precedence is intentionally not
* exposed — it's available on the runtime side as a CLI-only concession to
* legacy behavior, but SDK consumers always get the composable semantics.
*
* @internal
*/
private resolveToolFilterOptions(config: {
availableTools?: string[] | ToolSet;
excludedTools?: string[] | ToolSet;
}): {
availableTools: string[] | undefined;
excludedTools: string[] | undefined;
toolFilterPrecedence: "excluded";
} {
const availableTools = toolFilterListToArray(config.availableTools);
const excludedTools = toolFilterListToArray(config.excludedTools);
validateToolFilterList("availableTools", availableTools);
validateToolFilterList("excludedTools", excludedTools);
if (this.options.mode === "empty") {
if (availableTools === undefined) {
throw new Error(
"CopilotClient is in mode: 'empty' but the session config did not " +
"specify 'availableTools'. Empty mode requires every session to " +
"explicitly opt into the tools it wants — e.g. " +
"`new ToolSet().addBuiltIn(BuiltInTools.Isolated)`."
);
}
}
return { availableTools, excludedTools, toolFilterPrecedence: "excluded" };
}
private configDefaultsForMode(): Partial<SessionConfigBase> {
if (this.options.mode === "empty") {
return {
enableSessionTelemetry: false,
mcpOAuthTokenStorage: "in-memory",
skipEmbeddingRetrieval: true,
embeddingCacheStorage: "in-memory",
enableOnDemandInstructionDiscovery: false,
enableFileHooks: false,
enableHostGitOperations: false,
enableSessionStore: false,
enableSkills: false,
memory: { enabled: false },
customAgentsLocalOnly: true,
};
}
return {};
}
private experimentalModeForMode(supplied: boolean | undefined): boolean | undefined {
return this.options.mode === "empty" ? (supplied ?? false) : supplied;
}
* Returns the systemMessage config to use, adjusted for the current mode.
* In empty mode we ensure the environment_context section is removed
* unless the app has already taken control of it. `append` (and
* unspecified) mode is promoted to `customize` so we can also strip
* environment_context; the caller's `content` is preserved verbatim
* because the runtime appends it as additional instructions in both
* customize and append modes.
*/
private getSystemMessageConfigForMode(
supplied: SystemMessageConfig | undefined
): SystemMessageConfig | undefined {
if (this.options.mode !== "empty") return supplied;
if (!supplied) {
return {
mode: "customize",
sections: { environment_context: { action: "remove" } },
};
}
switch (supplied.mode) {
case "replace":
return supplied;
case "customize":
if (supplied.sections?.environment_context) return supplied;
return {
...supplied,
sections: {
...supplied.sections,
environment_context: { action: "remove" },
},
};
case "append":
case undefined:
return {
mode: "customize",
content: supplied.content,
sections: { environment_context: { action: "remove" } },
};
}
}
* Mode-specific options applied via session.options.update after create/resume.
*
* In empty mode, defaults the four overridable feature flags to safe values
* (caller values from `config` win). `installedPlugins=[]` is unconditional
* in empty mode. `includedBuiltinSkills` defaults to `[]`, but callers can
* explicitly allow selected runtime-bundled skills.
*/
private async updateSessionOptionsForMode(
session: CopilotSession,
config: SessionConfigBase
): Promise<void> {
const patch: SessionUpdateOptionsParams = {};
if (this.options.mode === "empty") {
patch.skipCustomInstructions = config.skipCustomInstructions ?? true;
patch.customAgentsLocalOnly = config.customAgentsLocalOnly ?? true;
patch.coauthorEnabled = config.coauthorEnabled ?? false;
patch.manageScheduleEnabled = config.manageScheduleEnabled ?? false;
patch.installedPlugins = [];
patch.includedBuiltinSkills = config.includedBuiltinSkills ?? [];
} else {
if (config.skipCustomInstructions !== undefined)
patch.skipCustomInstructions = config.skipCustomInstructions;
if (config.customAgentsLocalOnly !== undefined)
patch.customAgentsLocalOnly = config.customAgentsLocalOnly;
if (config.coauthorEnabled !== undefined)
patch.coauthorEnabled = config.coauthorEnabled;
if (config.manageScheduleEnabled !== undefined)
patch.manageScheduleEnabled = config.manageScheduleEnabled;
if (config.includedBuiltinSkills !== undefined)
patch.includedBuiltinSkills = config.includedBuiltinSkills;
}
if (Object.keys(patch).length === 0) {
return;
}
try {
await session.rpc.options.update(patch);
} catch (e) {
try {
await session.disconnect();
} catch {
}
throw e;
}
}
async createSession(config: SessionConfig): Promise<CopilotSession> {
if (config.gitHubToken !== undefined && config.gitHubTokenProvider !== undefined) {
throw new Error("gitHubToken and gitHubTokenProvider are mutually exclusive");
}
if (config.cloud != null && config.skillProvider) {
throw new Error("Skill providers are not supported for cloud sessions.");
}
if (!this.connection) {
await this.start();
}
const modeDefaults = this.configDefaultsForMode();
config = { ...modeDefaults, ...config };
config.customAgentsLocalOnly ??= modeDefaults.customAgentsLocalOnly;
config.systemMessage = this.getSystemMessageConfigForMode(config.systemMessage);
const callerSessionId = config.sessionId;
const useServerGeneratedId = config.cloud != null && callerSessionId == null;
const localSessionId = useServerGeneratedId ? undefined : (callerSessionId ?? randomUUID());
const toolFilterOptions = this.resolveToolFilterOptions(config);
const gitHubTokenProviderRegistrationId = this.registerGitHubTokenProvider(
config.gitHubTokenProvider,
localSessionId
);
const {
wireProvider: bearerWireProvider,
wireProviders: bearerWireProviders,
callbacks: bearerTokenCallbacks,
} = extractBearerTokenProviders(config.provider, config.providers);
const { wirePayload: wireSystemMessage, transformCallbacks } = extractTransformCallbacks(
config.systemMessage
);
const initializeSession = (sessionId: string): CopilotSession => {
const s = new CopilotSession(
sessionId,
this.connection!,
undefined,
this.onGetTraceContext,
{
mcpAuthHandler: config.onMcpAuthRequest,
managedSettingsEnabled:
config.enableManagedSettings === true ||
config.managedSettings !== undefined,
onDisconnected:
gitHubTokenProviderRegistrationId === undefined
? undefined
: () =>
this.githubTokenProviders.delete(
gitHubTokenProviderRegistrationId
),
}
);
s.registerTools(config.tools);
s.registerCanvases(config.canvases);
s.registerCommands(config.commands);
if (bearerTokenCallbacks.size > 0) {
s.registerBearerTokenProviders(bearerTokenCallbacks);
}
s.registerPermissionHandler(config.onPermissionRequest);
if (config.onUserInputRequest) {
s.registerUserInputHandler(config.onUserInputRequest);
}
if (config.onElicitationRequest) {
s.registerElicitationHandler(config.onElicitationRequest);
}
if (config.onExitPlanModeRequest) {
s.registerExitPlanModeHandler(config.onExitPlanModeRequest);
}
if (config.onAutoModeSwitchRequest) {
s.registerAutoModeSwitchHandler(config.onAutoModeSwitchRequest);
}
if (config.hooks) {
s.registerHooks(config.hooks);
}
if (config.skillProvider) {
s.registerSkillProvider(config.skillProvider);
}
if (transformCallbacks) {
s.registerTransformCallbacks(transformCallbacks);
}
if (config.onEvent) {
s.on(config.onEvent);
}
this.setupSessionFs(s, config);
this.sessions.set(sessionId, s);
return s;
};
let session: CopilotSession | undefined;
let registeredId: string | undefined;
let uninitializedCloudSessionId: string | undefined;
if (localSessionId !== undefined) {
try {
session = initializeSession(localSessionId);
registeredId = localSessionId;
} catch (error) {
if (gitHubTokenProviderRegistrationId !== undefined) {
this.githubTokenProviders.delete(gitHubTokenProviderRegistrationId);
}
throw error;
}
}
try {
const response = await this.connection!.sendRequest("session.create", {
...(await getTraceContext(this.onGetTraceContext)),
model: config.model,
allowedModels: config.allowedModels,
sessionId: localSessionId,
name: config.name,
clientName: config.clientName,
reasoningEffort: config.reasoningEffort,
reasoningSummary: config.reasoningSummary,
isExperimentalMode: this.experimentalModeForMode(config.enableExperimentalMode),
contextTier: config.contextTier,
tools: config.tools?.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: toJsonSchema(tool.parameters),
overridesBuiltInTool: tool.overridesBuiltInTool,
skipPermission: tool.skipPermission,
defer: tool.defer,
metadata: tool.metadata,
isTerminal: tool.isTerminal,
namespace: tool.namespace,
})),
toolSearch: config.toolSearch,
imageGeneration: config.imageGeneration,
canvases: config.canvases?.map((canvas) => canvas.declaration),
requestCanvasRenderer: config.requestCanvasRenderer,
requestExtensions: config.requestExtensions,
extensionSdkPath: config.extensionSdkPath,
extensionInfo: config.extensionInfo,
canvasProvider: config.canvasProvider,
commands: config.commands?.map((cmd) => ({
name: cmd.name,
description: cmd.description ?? "",
})),
systemMessage: wireSystemMessage,
availableTools: toolFilterOptions.availableTools,
excludedTools: toolFilterOptions.excludedTools,
toolFilterPrecedence: toolFilterOptions.toolFilterPrecedence,
excludedBuiltinAgents: config.excludedBuiltinAgents,
provider: bearerWireProvider,
capi: config.capi,
providers: bearerWireProviders,
models: config.models,
enableSessionTelemetry: config.enableSessionTelemetry,
enableCitations: config.enableCitations,
enableFileChangeTracking: config.enableFileChangeTracking,
sessionLimits: config.sessionLimits,
autopilot: config.autopilot,
modelCapabilities: config.modelCapabilities,
largeOutput: toWireLargeOutput(config.largeOutput),
requestPermission: !!config.onPermissionRequest,
requestUserInput: !!config.onUserInputRequest,
requestElicitation: !!config.onElicitationRequest,
askUserVariant: config.askUserVariant,
...(config.enableMcpApps ? { requestMcpApps: true } : {}),
...(config.githubMcpToolConfig != null
? { githubMcpToolConfig: config.githubMcpToolConfig }
: {}),
requestExitPlanMode: !!config.onExitPlanModeRequest,
requestAutoModeSwitch: !!config.onAutoModeSwitchRequest,
hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)),
workingDirectory: config.workingDirectory,
trustWorkingDirectory: config.trustWorkingDirectory,
additionalDirectories: config.additionalDirectories,
streaming: config.streaming,
includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true,
...(this.onGitHubTelemetry != null
? { enableGitHubTelemetryForwarding: true }
: {}),
mcpServers: toWireMcpServers(config.mcpServers),
...(config.diagnostics !== undefined ? { diagnostics: config.diagnostics } : {}),
mcpOAuthTokenStorage: config.mcpOAuthTokenStorage,
authClientIdMetadataUrl: config.authClientIdMetadataUrl,
envValueMode: "direct",
customAgents: toWireCustomAgents(config.customAgents),
customAgentsLocalOnly: config.customAgentsLocalOnly,
defaultAgent: config.defaultAgent,
agent: config.agent,
configDir: config.configDirectory,
enableConfigDiscovery: config.enableConfigDiscovery,
refreshCustomInstructions: config.refreshCustomInstructions,
skipEmbeddingRetrieval: config.skipEmbeddingRetrieval,
embeddingCacheStorage: config.embeddingCacheStorage,
organizationCustomInstructions: config.organizationCustomInstructions,
enableOnDemandInstructionDiscovery: config.enableOnDemandInstructionDiscovery,
enableFileHooks: config.enableFileHooks,
enableHostGitOperations: config.enableHostGitOperations,
enableSessionStore: config.enableSessionStore,
enableSkills: config.enableSkills,
skillDirectories: config.skillDirectories,
...(config.skillProvider ? { hasSkillProvider: true } : {}),
agentDirectories: config.agentDirectories,
hookDirectories: config.hookDirectories,
pluginDirectories: config.pluginDirectories,
instructionDirectories: config.instructionDirectories,
disabledSkills: config.disabledSkills,
disabledMcpServers: config.disabledMcpServers,
sandboxConfig: config.sandboxConfig,
infiniteSessions: config.infiniteSessions,
memory: config.memory,
gitHubToken: config.gitHubToken,
gitHubTokenProviderRegistrationId,
cloud: config.cloud,
featureFlags: config.featureFlags,
expAssignments: config.expAssignments,
enableManagedSettings: config.enableManagedSettings,
excludedManagedSettings: config.excludedManagedSettings,
enforceManagedModelDefaults: config.enforceManagedModelDefaults,
interactiveUser: config.interactiveUser,
managedSettings: config.managedSettings,
});
const {
sessionId: returnedSessionId,
workspacePath,
capabilities,
} = response as {
sessionId: string;
workspacePath?: string;
capabilities?: SessionCapabilities;
};
if (!returnedSessionId) {
throw new Error("session.create response did not include a sessionId");
}
if (localSessionId !== undefined && localSessionId !== returnedSessionId) {
throw new Error(
`session.create returned sessionId ${returnedSessionId} but the caller requested ${localSessionId}`
);
}
if (session === undefined) {
uninitializedCloudSessionId = returnedSessionId;
session = initializeSession(returnedSessionId);
uninitializedCloudSessionId = undefined;
registeredId = returnedSessionId;
}
this.assignGitHubTokenProvider(gitHubTokenProviderRegistrationId, returnedSessionId);
if (config.onMcpAuthRequest) {
await this.connection!.sendRequest("session.eventLog.registerInterest", {
sessionId: returnedSessionId,
eventType: "mcp.oauth_required",
});
}
session["_workspacePath"] = workspacePath;
session.setCapabilities(capabilities);
await this.updateSessionOptionsForMode(session, config);
this.commitGitHubTokenProvider(returnedSessionId, gitHubTokenProviderRegistrationId);
} catch (e) {
session?._markDisconnected();
if (registeredId !== undefined) {
this.sessions.delete(registeredId);
}
if (gitHubTokenProviderRegistrationId !== undefined) {
this.githubTokenProviders.delete(gitHubTokenProviderRegistrationId);
}
if (uninitializedCloudSessionId !== undefined) {
try {
await withTimeout(
this.deleteSession(uninitializedCloudSessionId),
CLOUD_SESSION_CLEANUP_TIMEOUT_MS,
`session.delete timed out after ${CLOUD_SESSION_CLEANUP_TIMEOUT_MS}ms`
);
} catch (cleanupError) {
throw new AggregateError(
[e, cleanupError],
"Failed to initialize and delete cloud session"
);
}
}
throw e;
}
for (const entry of this.hostHandoffs.values()) {
if (entry.requestedSessionId === session.sessionId) {
entry.configs.set(session, config);
}
}
return session;
}
* Resumes an existing conversation session by its ID.
*
* This allows you to continue a previous conversation, maintaining all
* conversation history. The session must have been previously created
* and not deleted.
*
* @param sessionId - The ID of the session to resume
* @param config - Optional configuration for the resumed session
* @returns A promise that resolves with the resumed session
* @throws Error if the session does not exist or the client is not connected
*
* @example
* ```typescript
* // Resume a previous session
* const session = await client.resumeSession("session-123", { onPermissionRequest: approveAll });
*
* // Resume with new tools
* const session = await client.resumeSession("session-123", {
* onPermissionRequest: approveAll,
* tools: [myNewTool]
* });
* ```
*/
async resumeSession(sessionId: string, config: ResumeSessionConfig): Promise<CopilotSession> {
return this.resumeSessionInternal(sessionId, config);
}
async resumeSessionForExtension(
sessionId: string,
config: ResumeSessionConfig,
contributions: ExtensionOrchestrationContributions = {},
extensionOptions?: ExtensionJoinOptions
): Promise<CopilotSession> {
return this.resumeSessionInternal(sessionId, config, contributions, extensionOptions);
}
private async resumeSessionInternal(
sessionId: string,
config: ResumeSessionConfig,
contributions: ExtensionOrchestrationContributions = {},
extensionOptions?: ExtensionJoinOptions
): Promise<CopilotSession> {
const { workflows } = contributions;
if (config.gitHubToken !== undefined && config.gitHubTokenProvider !== undefined) {
throw new Error("gitHubToken and gitHubTokenProvider are mutually exclusive");
}
if (!this.connection) {
await this.start();
}
const session = new CopilotSession(
sessionId,
this.connection!,
undefined,
this.onGetTraceContext,
{
mcpAuthHandler: config.onMcpAuthRequest,
managedSettingsEnabled:
config.enableManagedSettings === true || config.managedSettings !== undefined,
}
);
session.registerTools(config.tools);
session.registerCanvases(config.canvases);
session.registerCommands(config.commands);
session.registerWorkflows(workflows);
const {
wireProvider: bearerWireProvider,
wireProviders: bearerWireProviders,
callbacks: bearerTokenCallbacks,
} = extractBearerTokenProviders(config.provider, config.providers);
if (bearerTokenCallbacks.size > 0) {
session.registerBearerTokenProviders(bearerTokenCallbacks);
}
session.registerPermissionHandler(config.onPermissionRequest);
if (config.onUserInputRequest) {
session.registerUserInputHandler(config.onUserInputRequest);
}
if (config.onElicitationRequest) {
session.registerElicitationHandler(config.onElicitationRequest);
}
if (config.onExitPlanModeRequest) {
session.registerExitPlanModeHandler(config.onExitPlanModeRequest);
}
if (config.onAutoModeSwitchRequest) {
session.registerAutoModeSwitchHandler(config.onAutoModeSwitchRequest);
}
if (config.hooks) {
session.registerHooks(config.hooks);
}
if (config.skillProvider) {
session.registerSkillProvider(config.skillProvider);
}
const modeDefaults = this.configDefaultsForMode();
config = { ...modeDefaults, ...config };
config.customAgentsLocalOnly ??= modeDefaults.customAgentsLocalOnly;
config.systemMessage = this.getSystemMessageConfigForMode(config.systemMessage);
const { wirePayload: wireSystemMessage, transformCallbacks } = extractTransformCallbacks(
config.systemMessage
);
if (transformCallbacks) {
session.registerTransformCallbacks(transformCallbacks);
}
if (config.onEvent) {
session.on(config.onEvent);
}
const toolFilterOptions = this.resolveToolFilterOptions(config);
this.setupSessionFs(session, config);
const replacedSession = this.sessions.get(sessionId);
this.sessions.set(sessionId, session);
const gitHubTokenProviderRegistrationId = this.registerGitHubTokenProvider(
config.gitHubTokenProvider,
sessionId
);
if (gitHubTokenProviderRegistrationId !== undefined) {
session._setOnDisconnected(() =>
this.githubTokenProviders.delete(gitHubTokenProviderRegistrationId)
);
}
try {
const response = await this.connection!.sendRequest("session.resume", {
allowedModels: config.allowedModels,
...(await getTraceContext(this.onGetTraceContext)),
sessionId,
allowTranscriptRecovery: config.allowTranscriptRecovery,
clientName: config.clientName,
model: config.model,
reasoningEffort: config.reasoningEffort,
reasoningSummary: config.reasoningSummary,
isExperimentalMode: this.experimentalModeForMode(config.enableExperimentalMode),
contextTier: config.contextTier,
systemMessage: wireSystemMessage,
availableTools: toolFilterOptions.availableTools,
excludedTools: toolFilterOptions.excludedTools,
toolFilterPrecedence: toolFilterOptions.toolFilterPrecedence,
enableSessionTelemetry: config.enableSessionTelemetry,
excludedBuiltinAgents: config.excludedBuiltinAgents,
enableCitations: config.enableCitations,
enableFileChangeTracking: config.enableFileChangeTracking,
sessionLimits: config.sessionLimits,
autopilot: config.autopilot,
tools: config.tools?.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: toJsonSchema(tool.parameters),
overridesBuiltInTool: tool.overridesBuiltInTool,
skipPermission: tool.skipPermission,
defer: tool.defer,
metadata: tool.metadata,
isTerminal: tool.isTerminal,
namespace: tool.namespace,
})),
toolSearch: config.toolSearch,
imageGeneration: config.imageGeneration,
canvases: config.canvases?.map((canvas) => canvas.declaration),
workflows: workflows?.map((workflow) => workflow.meta),
requestCanvasRenderer: config.requestCanvasRenderer,
requestExtensions: config.requestExtensions,
extensionSdkPath: config.extensionSdkPath,
extensionInfo: config.extensionInfo,
canvasProvider: config.canvasProvider,
commands: config.commands?.map((cmd) => ({
name: cmd.name,
description: cmd.description ?? "",
})),
provider: bearerWireProvider,
capi: config.capi,
providers: bearerWireProviders,
models: config.models,
modelCapabilities: config.modelCapabilities,
largeOutput: toWireLargeOutput(config.largeOutput),
requestPermission:
config.onPermissionRequest !== defaultJoinSessionPermissionHandler,
requestUserInput: !!config.onUserInputRequest,
requestElicitation: !!config.onElicitationRequest,
askUserVariant: config.askUserVariant,
...(config.enableMcpApps ? { requestMcpApps: true } : {}),
...(config.githubMcpToolConfig != null
? { githubMcpToolConfig: config.githubMcpToolConfig }
: {}),
requestExitPlanMode: !!config.onExitPlanModeRequest,
requestAutoModeSwitch: !!config.onAutoModeSwitchRequest,
hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)),
workingDirectory: config.workingDirectory,
additionalDirectories: config.additionalDirectories,
configDir: config.configDirectory,
enableConfigDiscovery: config.enableConfigDiscovery,
skipEmbeddingRetrieval: config.skipEmbeddingRetrieval,
embeddingCacheStorage: config.embeddingCacheStorage,
organizationCustomInstructions: config.organizationCustomInstructions,
enableOnDemandInstructionDiscovery: config.enableOnDemandInstructionDiscovery,
enableFileHooks: config.enableFileHooks,
enableHostGitOperations: config.enableHostGitOperations,
enableSessionStore: config.enableSessionStore,
enableSkills: config.enableSkills,
streaming: config.streaming,
includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true,
...(this.onGitHubTelemetry != null
? { enableGitHubTelemetryForwarding: true }
: {}),
mcpServers: toWireMcpServers(config.mcpServers),
...(config.diagnostics !== undefined ? { diagnostics: config.diagnostics } : {}),
mcpOAuthTokenStorage: config.mcpOAuthTokenStorage,
authClientIdMetadataUrl: config.authClientIdMetadataUrl,
envValueMode: "direct",
customAgents: toWireCustomAgents(config.customAgents),
customAgentsLocalOnly: config.customAgentsLocalOnly,
defaultAgent: config.defaultAgent,
agent: config.agent,
skillDirectories: config.skillDirectories,
...(config.skillProvider ? { hasSkillProvider: true } : {}),
agentDirectories: config.agentDirectories,
hookDirectories: config.hookDirectories,
pluginDirectories: config.pluginDirectories,
instructionDirectories: config.instructionDirectories,
disabledSkills: config.disabledSkills,
disabledMcpServers: config.disabledMcpServers,
sandboxConfig: config.sandboxConfig,
infiniteSessions: config.infiniteSessions,
memory: config.memory,
disableResume: config.suppressResumeEvent,
continuePendingWork: config.continuePendingWork,
gitHubToken: config.gitHubToken,
gitHubTokenProviderRegistrationId,
openCanvases: config.openCanvases,
featureFlags: config.featureFlags,
expAssignments: config.expAssignments,
enableManagedSettings: config.enableManagedSettings,
excludedManagedSettings: config.excludedManagedSettings,
enforceManagedModelDefaults: config.enforceManagedModelDefaults,
interactiveUser: config.interactiveUser,
managedSettings: config.managedSettings,
...(extensionOptions?.requestedEnvironmentVariables
? {
requestedEnvironmentVariables:
extensionOptions.requestedEnvironmentVariables,
}
: {}),
});
if (extensionOptions?.requestedEnvironmentVariables) {
const requested = new Set(extensionOptions.requestedEnvironmentVariables);
const { grantedEnvironmentVariables } = response as {
grantedEnvironmentVariables?: Record<string, string>;
};
for (const [name, value] of Object.entries(grantedEnvironmentVariables ?? {})) {
if (requested.has(name)) {
process.env[name] = value;
}
}
}
const { workspacePath, capabilities, openCanvases, transcriptRecovery } = response as {
sessionId: string;
workspacePath?: string;
capabilities?: SessionCapabilities;
openCanvases?: OpenCanvasInstance[];
transcriptRecovery?: TranscriptRecovery;
};
session["_workspacePath"] = workspacePath;
session.setCapabilities(capabilities);
session.setOpenCanvases(openCanvases ?? []);
session.setTranscriptRecovery(transcriptRecovery);
if (config.onMcpAuthRequest) {
await this.connection!.sendRequest("session.eventLog.registerInterest", {
sessionId,
eventType: "mcp.oauth_required",
});
}
await this.updateSessionOptionsForMode(session, config);
this.commitGitHubTokenProvider(sessionId, gitHubTokenProviderRegistrationId);
} catch (e) {
session._markDisconnected();
if (this.sessions.get(sessionId) === session) {
if (replacedSession) {
this.sessions.set(sessionId, replacedSession);
} else {
this.sessions.delete(sessionId);
}
}
if (gitHubTokenProviderRegistrationId !== undefined) {
this.githubTokenProviders.delete(gitHubTokenProviderRegistrationId);
}
throw e;
}
for (const entry of this.hostHandoffs.values()) {
if (entry.requestedSessionId === session.sessionId) {
entry.configs.set(session, { ...config, sessionId });
}
}
return session;
}
* Start a complete AHP listener hosted in-process by this runtime.
*
* This connection owns the host. Disposing it or disconnecting the client
* stops the listener without deleting sessions. The host uses a separate
* SDK connection to the same runtime, not another runtime process.
* Only one AHP host may own a compute-scoped catalog in an effective Copilot home
* at a time. Other SDK clients and sessions remain usable in that home.
* Select localServer, githubEnvironment, or both. An empty localServer
* selects 127.0.0.1 on an available port. `onExit` runs at most once; owner disconnection cannot
* acknowledge listener cleanup over the disconnected transport.
*
* @experimental
*/
async startAhpHost(options: AhpHostOptions): Promise<AhpHost> {
const authentication = options?.authentication;
if (!authentication || !["github", "none", "custom"].includes(authentication.mode)) {
throw new Error("An explicit authentication mode is required");
}
if (
authentication.mode === "custom" &&
(typeof authentication.requireIdentity !== "string" ||
!authentication.requireIdentity.trim() ||
typeof authentication.authenticate !== "function")
) {
throw new Error(
"Custom authentication requires a nonempty requireIdentity and authenticate callback"
);
}
if (authentication.mode !== "github" && options.githubEnvironment) {
throw new Error("githubEnvironment requires github authentication");
}
if (!options?.localServer && !options?.githubEnvironment) {
throw new Error("At least one of localServer or githubEnvironment is required");
}
if (this.state !== "connected") {
await this.start();
}
const rpc = this.rpc;
const hostId = randomUUID();
if (authentication.mode === "custom") {
this.hostAuthenticators.set(hostId, { ...authentication });
}
const {
computeId,
workspace,
allowCreatingNewSessions,
localServer,
githubEnvironment,
onExit,
createSession,
resumeSession,
onSessionReleased,
} = options;
if (createSession || resumeSession) {
this.hostSessionFactories.set(hostId, {
createSession,
resumeSession,
onSessionReleased,
policy: {
mode: authentication.mode,
requireIdentity:
authentication.mode === "custom"
? authentication.requireIdentity
: undefined,
},
});
}
if (onExit) {
this.hostExitCallbacks.set(hostId, onExit);
}
try {
const info = await rpc.host.start({
hostId,
authentication:
authentication.mode === "custom"
? { mode: "custom", requireIdentity: authentication.requireIdentity }
: { mode: authentication.mode },
computeId,
workspace,
localServer,
githubEnvironment,
sessionFactory: createSession ? true : undefined,
resumeFactory: resumeSession ? true : undefined,
allowCreatingNewSessions,
});
return new AhpHost(
info,
async () => {
await rpc.host.dispose({ hostId });
},
(sessionId) => rpc.host.publishSession({ hostId, sessionId }),
() => rpc.host.listSessions({ hostId }),
(sessionId) => rpc.host.unpublishSession({ hostId, sessionId }),
async (allowed) => {
await rpc.host.setSessionCreationPolicy({
hostId,
allowCreatingNewSessions: allowed,
});
}
);
} catch (error) {
this.hostExitCallbacks.delete(hostId);
this.releaseHostSessions(hostId);
throw error;
}
}
private handleHostExit(exit: AhpHostExit): void {
this.releaseHostSessions(exit.hostId);
const onExit = this.hostExitCallbacks.get(exit.hostId);
if (onExit) {
this.hostExitCallbacks.delete(exit.hostId);
const reportError = (error: unknown) => {
console.error("AHP host exit callback failed", { hostId: exit.hostId, error });
};
try {
void Promise.resolve(onExit(exit)).catch(reportError);
} catch (error) {
reportError(error);
}
}
}
private disconnectHosts(): void {
for (const hostId of new Set([
...this.hostExitCallbacks.keys(),
...this.hostSessionFactories.keys(),
...this.hostAuthenticators.keys(),
])) {
this.handleHostExit({
hostId,
reason: "ownerDisconnected",
error: "Owner connection closed; runtime cleanup cannot be acknowledged on this connection.",
});
}
}
private releaseHostSessions(hostId: string): void {
this.hostAuthenticators.delete(hostId);
for (const [requestId, entry] of this.hostAuthentications) {
if (entry.hostId === hostId) {
this.hostAuthentications.delete(requestId);
entry.controller.abort();
}
}
this.hostSessionFactories.delete(hostId);
for (const [handoffId, entry] of this.hostHandoffs) {
if (entry.hostId === hostId) this.releaseHostSession(handoffId);
}
}
private releaseHostSession(handoffId: string): void {
const entry = this.hostHandoffs.get(handoffId);
if (!entry) return;
this.hostHandoffs.delete(handoffId);
entry.released = true;
entry.configs.clear();
entry.controller.abort();
if (entry.session) this.notifyHostSessionReleased(entry);
}
private notifyHostSessionReleased(entry: {
session?: CopilotSession;
onReleased?: AhpHostOptions["onSessionReleased"];
}): void {
const { session, onReleased } = entry;
entry.session = undefined;
if (session && onReleased) {
try {
void Promise.resolve(onReleased(session)).catch(() => {
console.error("AHP session release callback failed");
});
} catch {
console.error("AHP session release callback failed");
}
}
}
private async createHostSession(params: {
hostId: string;
handoffId: string;
resume?: boolean;
authentication?: AhpSessionCreateRequest["authentication"];
config: Omit<SessionConfig, "onPermissionRequest"> & { configDir?: string };
}): Promise<{ sessionId: string }> {
const authentication = params.authentication;
if (!authentication) {
throw new Error("AHP session handoff authentication is required");
}
const options = this.hostSessionFactories.get(params.hostId);
if (
!(params.resume ? options?.resumeSession : options?.createSession) ||
this.hostHandoffs.has(params.handoffId)
) {
throw new Error("AHP session factory is unavailable or handoff already exists");
}
const policy = options!.policy;
const identity = (authentication as { identity?: unknown }).identity;
if (
authentication.mode !== policy.mode ||
(authentication.mode === "none"
? identity !== undefined
: typeof identity !== "string" || !identity.trim()) ||
(policy.mode === "custom" && identity !== policy.requireIdentity)
) {
throw new Error("AHP trusted authentication does not match host policy");
}
const entry: {
hostId: string;
requestedSessionId?: string;
controller: AbortController;
configs: Map<CopilotSession, SessionConfig>;
session?: CopilotSession;
released: boolean;
onReleased?: AhpHostOptions["onSessionReleased"];
} = {
hostId: params.hostId,
requestedSessionId: params.config.sessionId,
controller: new AbortController(),
configs: new Map(),
released: false,
onReleased: options!.onSessionReleased,
};
this.hostHandoffs.set(params.handoffId, entry);
const { configDir, ...config } = params.config;
if (configDir !== undefined) config.configDirectory = configDir;
const expected = structuredClone(config);
const cancelled = new Promise<never>((_resolve, reject) => {
entry.controller.signal.addEventListener(
"abort",
() => {
reject(new Error("AHP session handoff ended"));
},
{ once: true }
);
});
const materialized = Promise.resolve()
.then(() => {
if (params.resume) {
const { sessionId, ...resumeConfig } = config;
if (!sessionId) throw new Error("AHP resume requires sessionId");
return options!.resumeSession!({
authentication,
sessionId,
config: resumeConfig,
signal: entry.controller.signal,
});
}
return options!.createSession!({
authentication,
config,
signal: entry.controller.signal,
});
})
.then((session) => {
entry.session = session;
const actual = entry.configs.get(session);
entry.configs.clear();
if (entry.released) {
this.notifyHostSessionReleased(entry);
throw new Error("AHP session handoff ended");
}
if (
this.sessions.get(session.sessionId) !== session ||
session.sessionId !== expected.sessionId
) {
throw new Error(
"AHP callback must return the requested session from this client"
);
}
const contains = (value: unknown, required: unknown): boolean => {
if (required && typeof required === "object" && !Array.isArray(required)) {
return (
!!value &&
typeof value === "object" &&
Object.entries(required).every(([key, child]) =>
contains((value as Record<string, unknown>)[key], child)
)
);
}
return JSON.stringify(value) === JSON.stringify(required);
};
if ((!params.resume || actual !== undefined) && !contains(actual, expected)) {
throw new Error(
"AHP callback must preserve the supplied session configuration"
);
}
return { sessionId: session.sessionId };
});
try {
return await Promise.race([materialized, cancelled]);
} catch (error) {
this.releaseHostSession(params.handoffId);
throw error;
}
}
* Sends a ping request to the server to verify connectivity.
*
* @param message - Optional message to include in the ping
* @returns A promise that resolves with the ping response containing the message and timestamp
* @throws Error if the client is not connected
*
* @example
* ```typescript
* const response = await client.ping("health check");
* console.log(`Server responded at ${new Date(response.timestamp)}`);
* ```
*/
async ping(
message?: string
): Promise<{ message: string; timestamp: string; protocolVersion?: number }> {
if (!this.connection) {
throw new Error("Client not connected");
}
const result = await this.connection.sendRequest("ping", { message });
return result as {
message: string;
timestamp: string;
protocolVersion?: number;
};
}
* Get CLI status including version and protocol information
*/
async getStatus(): Promise<GetStatusResponse> {
if (!this.connection) {
throw new Error("Client not connected");
}
const result = await this.connection.sendRequest("status.get", {});
return result as GetStatusResponse;
}
* Get current authentication status
*/
async getAuthStatus(): Promise<GetAuthStatusResponse> {
if (!this.connection) {
throw new Error("Client not connected");
}
const result = await this.connection.sendRequest("auth.getStatus", {});
return result as GetAuthStatusResponse;
}
* List available models with their metadata.
*
* If an `onListModels` handler was provided in the client options,
* it is called instead of querying the CLI server.
*
* Results are cached after the first successful call to avoid rate limiting.
* The cache is cleared when the client disconnects.
*
* @throws Error if not connected (when no custom handler is set)
*/
async listModels(): Promise<ModelInfo[]> {
await this.modelsCacheLock;
let resolveLock: () => void;
this.modelsCacheLock = new Promise((resolve) => {
resolveLock = resolve;
});
try {
if (this.modelsCache !== null) {
return [...this.modelsCache];
}
let models: ModelInfo[];
if (this.onListModels) {
models = await this.onListModels();
} else {
if (!this.connection) {
throw new Error("Client not connected");
}
const result = await this.connection.sendRequest("models.list", {});
const response = result as { models: ModelInfo[] };
models = response.models;
for (const model of models) {
const m = model as any;
if (!m.capabilities) {
m.capabilities = {
supports: {},
limits: { max_context_window_tokens: 0 },
};
} else {
if (!m.capabilities.supports) m.capabilities.supports = {};
if (!m.capabilities.limits) {
m.capabilities.limits = { max_context_window_tokens: 0 };
} else if (m.capabilities.limits.max_context_window_tokens === undefined) {
m.capabilities.limits.max_context_window_tokens = 0;
}
}
}
}
this.modelsCache = [...models];
return [...models];
} finally {
resolveLock!();
}
}
* Send the `connect` handshake (carrying the optional token) and verify the
* server's protocol version. Falls back to `ping` against legacy servers
* that don't implement `connect`.
*/
private async verifyProtocolVersion(): Promise<void> {
if (!this.connection) {
throw new Error("Client not connected");
}
const maxVersion = getSdkProtocolVersion();
const raceAgainstExit = <T>(p: Promise<T>): Promise<T> =>
this.processExitPromise ? Promise.race([p, this.processExitPromise]) : p;
let serverVersion: number | undefined;
try {
const connectParams: {
entra?: CopilotClientOptions["entra"];
token?: string;
enableGitHubTelemetryForwarding?: boolean;
clientInfo?: ConnectClientInfo;
supportedTaskKinds?: TaskKind[];
} = {
entra: this.options.entra,
token: this.effectiveConnectionToken,
supportedTaskKinds: ["agent", "client", "shell"],
};
if (this.onGitHubTelemetry != null) {
connectParams.enableGitHubTelemetryForwarding = true;
}
const clientInfo = clientInfoToWire(this.options.clientInfo);
if (clientInfo != null) {
connectParams.clientInfo = clientInfo;
}
const result = await raceAgainstExit(this.internalRpc.connect(connectParams));
serverVersion = result.protocolVersion;
} catch (err) {
if (
err instanceof ResponseError &&
(err.code === ErrorCodes.MethodNotFound ||
err.message === "Unhandled method connect")
) {
if (this.options.entra) {
throw new Error("This runtime does not support Entra client configuration.");
}
serverVersion = (await raceAgainstExit(this.ping())).protocolVersion;
} else {
throw err;
}
}
if (serverVersion === undefined) {
throw new Error(
`SDK protocol version mismatch: SDK supports versions ${MIN_PROTOCOL_VERSION}-${maxVersion}, but server does not report a protocol version. ` +
`Please update your server to ensure compatibility.`
);
}
if (serverVersion < MIN_PROTOCOL_VERSION || serverVersion > maxVersion) {
throw new Error(
`SDK protocol version mismatch: SDK supports versions ${MIN_PROTOCOL_VERSION}-${maxVersion}, but server reports version ${serverVersion}. ` +
`Please update your SDK or server to ensure compatibility.`
);
}
this.negotiatedProtocolVersion = serverVersion;
}
* Gets the ID of the most recently updated session.
*
* This is useful for resuming the last conversation when the session ID
* was not stored.
*
* @returns A promise that resolves with the session ID, or undefined if no sessions exist
* @throws Error if the client is not connected
*
* @example
* ```typescript
* const lastId = await client.getLastSessionId();
* if (lastId) {
* const session = await client.resumeSession(lastId, { onPermissionRequest: approveAll });
* }
* ```
*/
async getLastSessionId(): Promise<string | undefined> {
if (!this.connection) {
throw new Error("Client not connected");
}
const response = await this.connection.sendRequest("session.getLastId", {});
return (response as { sessionId?: string }).sessionId;
}
* Permanently deletes a session and all its data from disk, including
* conversation history, planning state, and artifacts.
*
* Unlike {@link CopilotSession.disconnect}, which only releases in-memory
* resources and preserves session data for later resumption, this method
* is irreversible. The session cannot be resumed after deletion.
*
* @param sessionId - The ID of the session to delete
* @returns A promise that resolves when the session is deleted
* @throws Error if the session does not exist or deletion fails
*
* @example
* ```typescript
* await client.deleteSession("session-123");
* ```
*/
async deleteSession(sessionId: string): Promise<void> {
if (!this.connection) {
throw new Error("Client not connected");
}
const response = await this.connection.sendRequest("session.delete", {
sessionId,
});
const { success, error } = response as { success: boolean; error?: string };
if (!success) {
throw new Error(`Failed to delete session ${sessionId}: ${error || "Unknown error"}`);
}
const session = this.sessions.get(sessionId);
this.sessions.delete(sessionId);
session?._runOnDisconnected();
}
* List all available sessions.
*
* @param filter - Optional filter to limit returned sessions by context fields
*
* @example
* // List all sessions
* const sessions = await client.listSessions();
*
* @example
* // List sessions for a specific repository
* const sessions = await client.listSessions({ repository: "owner/repo" });
*/
async listSessions(filter?: SessionListFilter): Promise<SessionMetadata[]> {
if (!this.connection) {
throw new Error("Client not connected");
}
let wireFilter: Record<string, unknown> | undefined;
if (filter) {
const { workingDirectory, ...rest } = filter;
wireFilter = { ...rest, cwd: workingDirectory };
}
const response = await this.connection.sendRequest("session.list", {
filter: wireFilter,
});
const { sessions } = response as {
sessions: Array<{
sessionId: string;
startTime: string;
modifiedTime: string;
summary?: string;
isRemote: boolean;
context?: { cwd: string; gitRoot?: string; repository?: string; branch?: string };
}>;
};
return sessions.map(CopilotClient.toSessionMetadata);
}
* Gets metadata for a specific session by ID.
*
* This provides an efficient O(1) lookup of a single session's metadata
* instead of listing all sessions. Returns undefined if the session is not found.
*
* @param sessionId - The ID of the session to look up
* @returns A promise that resolves with the session metadata, or undefined if not found
* @throws Error if the client is not connected
*
* @example
* ```typescript
* const metadata = await client.getSessionMetadata("session-123");
* if (metadata) {
* console.log(`Session started at: ${metadata.startTime}`);
* }
* ```
*/
async getSessionMetadata(sessionId: string): Promise<SessionMetadata | undefined> {
if (!this.connection) {
throw new Error("Client not connected");
}
const response = await this.connection.sendRequest("session.getMetadata", { sessionId });
const { session } = response as {
session?: {
sessionId: string;
startTime: string;
modifiedTime: string;
summary?: string;
isRemote: boolean;
context?: { cwd: string; gitRoot?: string; repository?: string; branch?: string };
};
};
if (!session) {
return undefined;
}
return CopilotClient.toSessionMetadata(session);
}
private static toSessionMetadata(raw: {
sessionId: string;
startTime: string;
modifiedTime: string;
summary?: string;
isRemote: boolean;
context?: { cwd: string; gitRoot?: string; repository?: string; branch?: string };
}): SessionMetadata {
const { context } = raw;
return {
sessionId: raw.sessionId,
startTime: new Date(raw.startTime),
modifiedTime: new Date(raw.modifiedTime),
summary: raw.summary,
isRemote: raw.isRemote,
context: context
? {
workingDirectory: context.cwd,
gitRoot: context.gitRoot,
repository: context.repository,
branch: context.branch,
}
: undefined,
};
}
* Gets the foreground session ID in TUI+server mode.
*
* This returns the ID of the session currently displayed in the TUI.
* Only available when connecting to a server running in TUI+server mode (--ui-server).
*
* @returns A promise that resolves with the foreground session ID, or undefined if none
* @throws Error if the client is not connected
*
* @example
* ```typescript
* const sessionId = await client.getForegroundSessionId();
* if (sessionId) {
* console.log(`TUI is displaying session: ${sessionId}`);
* }
* ```
*/
async getForegroundSessionId(): Promise<string | undefined> {
if (!this.connection) {
throw new Error("Client not connected");
}
const response = await this.connection.sendRequest("session.getForeground", {});
return (response as ForegroundSessionInfo).sessionId;
}
* Sets the foreground session in TUI+server mode.
*
* This requests the TUI to switch to displaying the specified session.
* Only available when connecting to a server running in TUI+server mode (--ui-server).
*
* @param sessionId - The ID of the session to display in the TUI
* @returns A promise that resolves when the session is switched
* @throws Error if the client is not connected or if the operation fails
*
* @example
* ```typescript
* // Switch the TUI to display a specific session
* await client.setForegroundSessionId("session-123");
* ```
*/
async setForegroundSessionId(sessionId: string): Promise<void> {
if (!this.connection) {
throw new Error("Client not connected");
}
const response = await this.connection.sendRequest("session.setForeground", { sessionId });
const result = response as { success: boolean; error?: string };
if (!result.success) {
throw new Error(result.error || "Failed to set foreground session");
}
}
* Subscribes to a specific session lifecycle event type.
*
* Lifecycle events are emitted when sessions are created, deleted, updated,
* or change foreground/background state (in TUI+server mode).
*
* @param eventType - The specific event type to listen for
* @param handler - A callback function that receives events of the specified type
* @returns A function that, when called, unsubscribes the handler
*
* @example
* ```typescript
* // Listen for when a session becomes foreground in TUI
* const unsubscribe = client.onLifecycle("session.foreground", (event) => {
* console.log(`Session ${event.sessionId} is now displayed in TUI`);
* });
*
* // Later, to stop receiving events:
* unsubscribe();
* ```
*/
onLifecycle<K extends SessionLifecycleEventType>(
eventType: K,
handler: TypedSessionLifecycleHandler<K>
): () => void;
* Subscribes to all session lifecycle events.
*
* @param handler - A callback function that receives all lifecycle events
* @returns A function that, when called, unsubscribes the handler
*
* @example
* ```typescript
* const unsubscribe = client.onLifecycle((event) => {
* switch (event.type) {
* case "session.foreground":
* console.log(`Session ${event.sessionId} is now in foreground`);
* break;
* case "session.created":
* console.log(`New session created: ${event.sessionId}`);
* break;
* }
* });
*
* // Later, to stop receiving events:
* unsubscribe();
* ```
*/
onLifecycle(handler: SessionLifecycleHandler): () => void;
onLifecycle<K extends SessionLifecycleEventType>(
eventTypeOrHandler: K | SessionLifecycleHandler,
handler?: TypedSessionLifecycleHandler<K>
): () => void {
if (typeof eventTypeOrHandler === "string" && handler) {
const eventType = eventTypeOrHandler;
if (!this.typedLifecycleHandlers.has(eventType)) {
this.typedLifecycleHandlers.set(eventType, new Set());
}
const storedHandler = handler as (event: SessionLifecycleEvent) => void;
this.typedLifecycleHandlers.get(eventType)!.add(storedHandler);
return () => {
const handlers = this.typedLifecycleHandlers.get(eventType);
if (handlers) {
handlers.delete(storedHandler);
}
};
}
const wildcardHandler = eventTypeOrHandler as SessionLifecycleHandler;
this.sessionLifecycleHandlers.add(wildcardHandler);
return () => {
this.sessionLifecycleHandlers.delete(wildcardHandler);
};
}
* Builds the environment for the spawned runtime child process (stdio/TCP): applies
* the auth token, connection token, `COPILOT_HOME`, keychain setting, and telemetry
* variables on top of the effective env. Not used by the in-process (FFI) transport,
* whose worker inherits the host process's ambient environment
* (see {@link CopilotClient.startInProcessFfi}).
*/
private buildRuntimeEnv(): Record<string, string | undefined> {
const env: Record<string, string | undefined> = { ...this.resolvedEnv };
delete env.NODE_DEBUG;
if (this.options.gitHubToken) {
env.COPILOT_SDK_AUTH_TOKEN = this.options.gitHubToken;
}
if (this.effectiveConnectionToken) {
env.COPILOT_CONNECTION_TOKEN = this.effectiveConnectionToken;
}
if (this.options.baseDirectory) {
env.COPILOT_HOME = this.options.baseDirectory;
}
if (this.options.mode === "empty") {
env.COPILOT_DISABLE_KEYTAR = "1";
}
if (this.options.mode !== "empty") {
env.COPILOT_RUNTIME_PROCESS_FILE_LOGGING = "1";
}
if (this.options.telemetry) {
const t = this.options.telemetry;
env.COPILOT_OTEL_ENABLED = "true";
if (t.otlpEndpoint !== undefined) env.OTEL_EXPORTER_OTLP_ENDPOINT = t.otlpEndpoint;
if (t.otlpProtocol !== undefined) env.OTEL_EXPORTER_OTLP_PROTOCOL = t.otlpProtocol;
if (t.filePath !== undefined) env.COPILOT_OTEL_FILE_EXPORTER_PATH = t.filePath;
if (t.exporterType !== undefined) env.COPILOT_OTEL_EXPORTER_TYPE = t.exporterType;
if (t.sourceName !== undefined) env.COPILOT_OTEL_SOURCE_NAME = t.sourceName;
if (t.captureContent !== undefined)
env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = String(t.captureContent);
}
return env;
}
* Start the CLI server process
*/
private async startCLIServer(): Promise<void> {
this.resolvedCliPath ??= await getBundledRuntimePath();
return new Promise((resolve, reject) => {
this.stderrBuffer = "";
const args = [...this.connectionExtraArgs, "--headless", "--no-auto-update"];
if (this.options.logLevel) {
args.push("--log-level", this.options.logLevel);
}
if (this.connectionConfig.kind === "stdio") {
args.push("--stdio");
} else if (this.connectionConfig.kind === "tcp") {
const requestedPort = this.connectionConfig.port ?? 0;
if (requestedPort > 0) {
args.push("--port", requestedPort.toString());
}
}
if (this.options.gitHubToken) {
args.push("--auth-token-env", "COPILOT_SDK_AUTH_TOKEN");
}
if (!this.options.useLoggedInUser) {
args.push("--no-auto-login");
}
if (
this.options.sessionIdleTimeoutSeconds !== undefined &&
this.options.sessionIdleTimeoutSeconds > 0
) {
args.push(
"--session-idle-timeout",
this.options.sessionIdleTimeoutSeconds.toString()
);
}
const envWithoutNodeDebug = this.buildRuntimeEnv();
if (!this.resolvedCliPath) {
throw new Error(
"Path to Copilot CLI is required. Please supply it via " +
"`RuntimeConnection.forStdio({ path })` or " +
"`RuntimeConnection.forTcp({ path })`, set the COPILOT_CLI_PATH " +
"environment variable, or use `RuntimeConnection.forUri(...)` to " +
"connect to an already-running runtime."
);
}
if (!existsSync(this.resolvedCliPath)) {
throw new Error(
`Copilot CLI not found at ${this.resolvedCliPath}. Set COPILOT_CLI_PATH to use a custom installation.`
);
}
const stdioConfig: ["pipe", "pipe", "pipe"] | ["ignore", "pipe", "pipe"] =
this.connectionConfig.kind === "stdio"
? ["pipe", "pipe", "pipe"]
: ["ignore", "pipe", "pipe"];
const isJsFile = this.resolvedCliPath.endsWith(".js");
if (isJsFile) {
this.cliProcess = spawn(getNodeExecPath(), [this.resolvedCliPath, ...args], {
stdio: stdioConfig,
cwd: this.options.workingDirectory,
env: envWithoutNodeDebug,
windowsHide: true,
});
} else {
this.cliProcess = spawn(this.resolvedCliPath, args, {
stdio: stdioConfig,
cwd: this.options.workingDirectory,
env: envWithoutNodeDebug,
windowsHide: true,
});
}
let stdout = "";
let resolved = false;
if (this.connectionConfig.kind === "stdio") {
resolved = true;
resolve();
} else {
this.cliProcess.stdout?.on("data", (data: Buffer) => {
stdout += data.toString();
const match = stdout.match(/listening on port (\d+)/i);
if (match && !resolved) {
this.runtimePort = parseInt(match[1], 10);
resolved = true;
resolve();
}
});
}
this.cliProcess.stderr?.on("data", (data: Buffer) => {
this.stderrBuffer += data.toString();
const lines = data.toString().split("\n");
for (const line of lines) {
if (line.trim()) {
process.stderr.write(`[CLI subprocess] ${line}\n`);
}
}
});
this.cliProcess.on("error", (error) => {
if (!resolved) {
resolved = true;
const stderrOutput = this.stderrBuffer.trim();
if (stderrOutput) {
reject(
new Error(
`Failed to start CLI server: ${error.message}\nstderr: ${stderrOutput}`
)
);
} else {
reject(new Error(`Failed to start CLI server: ${error.message}`));
}
}
});
this.processExitPromise = new Promise<never>((_, rejectProcessExit) => {
this.cliProcess!.on("exit", (code) => {
if (this.messageWriter) {
this.messageWriter.suppressWriteErrors = true;
}
const stderrOutput = this.stderrBuffer.trim();
if (stderrOutput) {
rejectProcessExit(
new Error(
`CLI server exited with code ${code}\nstderr: ${stderrOutput}`
)
);
} else {
rejectProcessExit(
new Error(`CLI server exited unexpectedly with code ${code}`)
);
}
});
});
this.processExitPromise.catch(() => {});
this.cliProcess.on("close", (code) => {
if (!resolved) {
resolved = true;
const stderrOutput = this.stderrBuffer.trim();
if (stderrOutput) {
reject(
new Error(
`CLI server exited with code ${code}\nstderr: ${stderrOutput}`
)
);
} else {
reject(new Error(`CLI server exited with code ${code}`));
}
}
});
this.cliStartTimeout = setTimeout(() => {
if (!resolved) {
resolved = true;
reject(
new Error(
"Timeout waiting for CLI server to start" +
`\nstdout: ${stdout.trim().slice(-8192) || "(empty)"}` +
`\nstderr: ${this.stderrBuffer.trim().slice(-8192) || "(empty)"}`
)
);
}
}, 30000);
});
}
* Connect to the CLI server (via socket or stdio)
*/
private async connectToServer(): Promise<void> {
switch (this.connectionConfig.kind) {
case "parent-process":
return this.connectToParentProcessViaStdio();
case "stdio":
return this.connectToChildProcessViaStdio();
case "inprocess":
return this.connectViaFfi();
case "tcp":
return this.connectViaTcp();
case "uri": {
const { host, port } = this.parseCliUrl(this.connectionConfig.url);
this.actualHost = host;
this.runtimePort = port;
return this.connectViaTcp();
}
}
}
private async startInProcessFfi(): Promise<void> {
const explicitEntrypoint = this.resolvedEnv.COPILOT_CLI_PATH;
const cliLoginSyncIntegrationId =
this.resolvedEnv.GITHUB_COPILOT_INTEGRATION_ID?.trim() || undefined;
let runtimeLibrary: string;
if (explicitEntrypoint) {
const entrypointDirectory = dirname(resolve(explicitEntrypoint));
const adjacentRuntime = join(entrypointDirectory, "runtime.node");
runtimeLibrary = existsSync(adjacentRuntime)
? adjacentRuntime
: join(
entrypointDirectory,
"prebuilds",
CopilotClient.getNapiPrebuildsFolder(explicitEntrypoint),
"runtime.node"
);
} else {
const bundledRuntimePath = await getBundledRuntimePath();
runtimeLibrary = join(dirname(bundledRuntimePath), "runtime.node");
}
const { FfiRuntimeHost } = await import("./ffiRuntimeHost.js");
const environment: Record<string, string> = {};
if (this.options.gitHubToken) {
environment.COPILOT_SDK_AUTH_TOKEN = this.options.gitHubToken;
}
if (this.options.baseDirectory) {
environment.COPILOT_HOME = this.options.baseDirectory;
}
if (this.options.mode === "empty") {
environment.COPILOT_DISABLE_KEYTAR = "1";
}
const args: string[] = [];
if (this.options.logLevel) {
args.push("--log-level", this.options.logLevel);
}
if (this.options.gitHubToken) {
args.push("--auth-token-env", "COPILOT_SDK_AUTH_TOKEN");
}
if (!this.options.useLoggedInUser) {
args.push("--no-auto-login");
}
if (this.options.sessionIdleTimeoutSeconds > 0) {
args.push("--session-idle-timeout", this.options.sessionIdleTimeoutSeconds.toString());
}
if (cliLoginSyncIntegrationId) {
args.push(`--cli-login-sync-integration-id=${cliLoginSyncIntegrationId}`);
}
const host = FfiRuntimeHost.create(runtimeLibrary, explicitEntrypoint, environment, args);
this.ffiHost = host;
await host.start();
}
* Connect to the in-process FFI runtime host over its receive/send streams,
* reusing the same `vscode-jsonrpc` framing as the stdio transport.
*/
private async connectViaFfi(): Promise<void> {
if (!this.ffiHost) {
throw new Error("In-process FFI runtime host not started");
}
this.messageWriter = new TeardownResilientStreamMessageWriter(this.ffiHost.sendStream);
this.connection = createMessageConnection(
new StreamMessageReader(this.ffiHost.receiveStream),
this.messageWriter
);
this.attachConnectionHandlers();
this.connection.listen();
this.ffiHost.configureUiThread(
this.connectionConfig.kind === "inprocess" &&
this.connectionConfig.runOnUiThread !== undefined
);
}
private static getNapiPrebuildsFolder(entrypoint: string): string {
const arch = process.arch;
if (arch !== "x64" && arch !== "arm64") {
throw new Error(`Unsupported architecture '${arch}' for in-process FFI hosting.`);
}
let platform: string = process.platform;
if (platform === "linux" && CopilotClient.isMusl(entrypoint)) {
platform = "linuxmusl";
}
return `${platform}-${arch}`;
}
private static isMusl(entrypoint: string): boolean {
if (entrypoint.includes(`copilot-linuxmusl-${process.arch}`)) {
return true;
}
if (entrypoint.includes(`copilot-linux-${process.arch}`)) {
return false;
}
const report = process.report?.getReport();
const header =
report && "header" in report
? (report.header as { glibcVersionRuntime?: string })
: undefined;
return header !== undefined && header.glibcVersionRuntime === undefined;
}
* Connect to child via stdio pipes
*/
private async connectToChildProcessViaStdio(): Promise<void> {
if (!this.cliProcess) {
throw new Error("CLI process not started");
}
this.cliProcess.stdin?.on("error", (err) => {
if (this.forceStopping) {
return;
}
this.state = "error";
const reason = err instanceof Error ? (err.stack ?? err.message) : String(err);
const stderrOutput = this.stderrBuffer.trim();
this.processTransportError = new Error(
`CLI server connection failed: ${reason}${stderrOutput ? `\nstderr: ${stderrOutput}` : ""}`
);
this.logDebug(`stdin pipe error: ${reason}`);
if (this.messageWriter) {
this.messageWriter.suppressWriteErrors = true;
}
try {
this.connection?.dispose();
} catch {
}
});
this.messageWriter = new TeardownResilientStreamMessageWriter(this.cliProcess.stdin!);
this.connection = createMessageConnection(
new StreamMessageReader(this.cliProcess.stdout!),
this.messageWriter
);
this.attachConnectionHandlers();
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");
}
this.messageWriter = new TeardownResilientStreamMessageWriter(process.stdout);
this.connection = createMessageConnection(
new StreamMessageReader(process.stdin),
this.messageWriter
);
this.attachConnectionHandlers();
this.connection.listen();
}
* Connect to the CLI server via TCP socket
*/
private async connectViaTcp(): Promise<void> {
if (!this.runtimePort) {
throw new Error("Server port not available");
}
return new Promise((resolve, reject) => {
this.socket = new Socket();
const connectionTimeout = setTimeout(() => {
this.socket?.destroy();
reject(new Error("Timeout connecting to CLI server"));
}, 10000);
this.socket.connect(this.runtimePort!, this.actualHost, () => {
clearTimeout(connectionTimeout);
this.messageWriter = new TeardownResilientStreamMessageWriter(this.socket!);
this.connection = createMessageConnection(
new StreamMessageReader(this.socket!),
this.messageWriter
);
this.attachConnectionHandlers();
this.connection.listen();
resolve();
});
this.socket.on("error", (error) => {
clearTimeout(connectionTimeout);
reject(new Error(`Failed to connect to CLI server: ${error.message}`));
});
});
}
private attachConnectionHandlers(): void {
if (!this.connection) {
return;
}
this.connection.onNotification("session.event", (notification: unknown) => {
this.handleSessionEventNotification(notification);
});
this.connection.onNotification("session.lifecycle", (notification: unknown) => {
this.handleSessionLifecycleNotification(notification);
});
this.connection.onRequest(
"userInput.request",
async (params: {
sessionId: string;
question: string;
choices?: string[];
allowFreeform?: boolean;
}): Promise<{ answer: string; wasFreeform: boolean }> =>
await this.handleUserInputRequest(params)
);
this.connection.onRequest(
"exitPlanMode.request",
async (
params: ExitPlanModeRequest & { sessionId: string }
): Promise<ExitPlanModeResult> => await this.handleExitPlanModeRequest(params)
);
this.connection.onRequest(
"autoModeSwitch.request",
async (
params: AutoModeSwitchRequest & { sessionId: string }
): Promise<{ response: AutoModeSwitchResponse }> =>
await this.handleAutoModeSwitchRequest(params)
);
this.connection.onRequest(
"systemMessage.transform",
async (params: {
sessionId: string;
sections: Record<string, { content: string }>;
}): Promise<{ sections: Record<string, { content: string }> }> =>
await this.handleSystemMessageTransform(params)
);
const sessions = this.sessions;
registerClientSessionApiHandlers(this.connection, (sessionId) => {
const session = sessions.get(sessionId);
if (!session) throw new Error(`No session found for sessionId: ${sessionId}`);
return session.clientSessionApis;
});
registerClientGlobalApiHandlers(this.connection, this.clientGlobalHandlers);
if (this.installationConfirmationHandler) {
const confirmation = createInstallationConfirmationAdapter(
this.connection,
this.installationConfirmationHandler
);
this.connection.onRequest(
"installations.confirm",
(params: InstallationConfirmationRequest, token: CancellationToken) =>
confirmation.confirm(params, token)
);
}
this.connection.onRequest("host.materializeSession", (params) =>
this.createHostSession(params)
);
this.connection.onRequest(
"host.validateAuthentication",
async (params: { hostId: string; requestId: string; token: string }) => {
const authenticator = this.hostAuthenticators.get(params.hostId);
if (!authenticator || this.hostAuthentications.has(params.requestId)) {
throw new Error("AHP authenticator is unavailable or request already exists");
}
const entry = { hostId: params.hostId, controller: new AbortController() };
this.hostAuthentications.set(params.requestId, entry);
const signal = entry.controller.signal;
const cancelled = new Promise<never>((_resolve, reject) => {
signal.addEventListener(
"abort",
() => reject(new Error("AHP authentication ended")),
{ once: true }
);
});
try {
const result = await Promise.race([
Promise.resolve().then(() =>
authenticator.authenticate({ token: params.token, signal })
),
cancelled,
]);
if (
signal.aborted ||
this.hostAuthenticators.get(params.hostId) !== authenticator
) {
throw new Error("AHP authentication ended");
}
return result;
} finally {
if (this.hostAuthentications.get(params.requestId) === entry) {
this.hostAuthentications.delete(params.requestId);
}
entry.controller.abort();
}
}
);
this.connection.onNotification(
"host.authenticationCancelled",
(params: { hostId: string; requestId: string }) => {
const entry = this.hostAuthentications.get(params.requestId);
if (entry?.hostId === params.hostId) entry.controller.abort();
}
);
this.connection.onNotification(
"host.sessionReleased",
(params: { hostId: string; handoffId: string }) => {
if (this.hostHandoffs.get(params.handoffId)?.hostId === params.hostId) {
this.releaseHostSession(params.handoffId);
}
}
);
this.connection.onRequest(
"hooks.invoke",
async (params: { sessionId: string; hookType: string; input: unknown }) => {
return await this.handleHooksInvoke(params);
}
);
this.connection.onRequest(
"skillProvider.list",
async (params: { sessionId: string }, token: CancellationToken) =>
await this.resolveSkillProviderSession(params)._handleSkillProviderList(token)
);
this.connection.onRequest(
"skillProvider.read",
async (params: { sessionId: string; name: string }, token: CancellationToken) => {
const session = this.resolveSkillProviderSession(params);
if (typeof params.name !== "string") {
throw new Error("Invalid skillProvider.read payload");
}
return await session._handleSkillProviderRead(params.name, token);
}
);
const connection = this.connection;
const messageWriter = this.messageWriter;
const cliProcess = this.isExternalServer ? null : this.cliProcess;
const connectionEpoch = this.connectionEpoch;
const isCurrentConnection = () =>
connectionEpoch === this.connectionEpoch &&
this.connection === connection &&
(cliProcess === null || this.cliProcess === cliProcess);
let disconnecting = false;
let exitFallback: ReturnType<typeof setTimeout> | undefined;
const markDisconnected = () => {
if (!isCurrentConnection()) {
connection.dispose();
return;
}
this.connectionClosed = true;
this.state = "disconnected";
this.disconnectHosts();
for (const session of this.sessions.values()) {
session._markDisconnected();
}
this.sessions.clear();
this.githubTokenProviders.clear();
this.requestAdapter?.cancelPending();
connection.dispose();
this.socket?.destroy();
this.connection = null;
this.messageWriter = null;
this._rpc = null;
this._internalRpc = null;
this.modelsCache = null;
};
const disconnectAfterDrain = () => {
if (exitFallback !== undefined) {
clearTimeout(exitFallback);
exitFallback = undefined;
}
if (!isCurrentConnection()) {
connection.dispose();
return;
}
if (disconnecting) return;
disconnecting = true;
this.connectionClosed = true;
if (messageWriter) messageWriter.suppressWriteErrors = true;
void connection.drain().then(markDisconnected);
};
this.connection.onClose(disconnectAfterDrain);
if (cliProcess) {
cliProcess.once("exit", () => {
if (!isCurrentConnection()) {
connection.dispose();
return;
}
if (disconnecting) return;
if (this.socket) {
disconnectAfterDrain();
return;
}
this.connectionClosed = true;
if (messageWriter) messageWriter.suppressWriteErrors = true;
exitFallback = setTimeout(disconnectAfterDrain, RUNTIME_SHUTDOWN_TIMEOUT_MS);
exitFallback.unref?.();
});
}
this.connection.onError(() => {
if (connectionEpoch === this.connectionEpoch && this.connection === connection) {
this.state = "disconnected";
}
});
}
private handleSessionEventNotification(notification: unknown): void {
if (
typeof notification !== "object" ||
!notification ||
!("sessionId" in notification) ||
typeof (notification as { sessionId?: unknown }).sessionId !== "string" ||
!("event" in notification)
) {
return;
}
const session = this.sessions.get((notification as { sessionId: string }).sessionId);
const event = (notification as { event: SessionEvent }).event;
if (session) {
session._dispatchEvent(event);
}
}
private handleSessionLifecycleNotification(notification: unknown): void {
if (
typeof notification !== "object" ||
!notification ||
!("type" in notification) ||
typeof (notification as { type?: unknown }).type !== "string" ||
!("sessionId" in notification) ||
typeof (notification as { sessionId?: unknown }).sessionId !== "string"
) {
return;
}
const raw = notification as {
type: SessionLifecycleEventType;
sessionId: string;
metadata?: { startTime?: string; modifiedTime?: string; summary?: string };
};
let metadata: SessionLifecycleEvent["metadata"];
if (raw.metadata && raw.metadata.startTime && raw.metadata.modifiedTime) {
metadata = {
startTime: new Date(raw.metadata.startTime),
modifiedTime: new Date(raw.metadata.modifiedTime),
summary: raw.metadata.summary,
};
}
const event = {
type: raw.type,
sessionId: raw.sessionId,
metadata,
} as SessionLifecycleEvent;
const typedHandlers = this.typedLifecycleHandlers.get(event.type);
if (typedHandlers) {
for (const handler of typedHandlers) {
try {
handler(event);
} catch {
}
}
}
for (const handler of this.sessionLifecycleHandlers) {
try {
handler(event);
} catch {
}
}
}
private async handleUserInputRequest(params: {
sessionId: string;
question: string;
choices?: string[];
allowFreeform?: boolean;
}): Promise<{ answer: string; wasFreeform: boolean }> {
if (
!params ||
typeof params.sessionId !== "string" ||
typeof params.question !== "string"
) {
throw new Error("Invalid user input request payload");
}
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}
const result = await session._handleUserInputRequest({
question: params.question,
choices: params.choices,
allowFreeform: params.allowFreeform,
});
return result;
}
private async handleExitPlanModeRequest(
params: ExitPlanModeRequest & { sessionId: string }
): Promise<ExitPlanModeResult> {
if (
!params ||
typeof params.sessionId !== "string" ||
typeof params.summary !== "string" ||
!Array.isArray(params.actions) ||
typeof params.recommendedAction !== "string"
) {
throw new Error("Invalid exit plan mode request payload");
}
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}
return await session._handleExitPlanModeRequest({
summary: params.summary,
planContent: params.planContent,
actions: params.actions,
recommendedAction: params.recommendedAction,
});
}
private async handleAutoModeSwitchRequest(
params: AutoModeSwitchRequest & { sessionId: string }
): Promise<{ response: AutoModeSwitchResponse }> {
if (!params || typeof params.sessionId !== "string") {
throw new Error("Invalid auto mode switch request payload");
}
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}
const response = await session._handleAutoModeSwitchRequest({
errorCode: params.errorCode,
retryAfterSeconds: params.retryAfterSeconds,
});
return { response };
}
private resolveSkillProviderSession(params: { sessionId: string }): CopilotSession {
if (!params || typeof params.sessionId !== "string") {
throw new Error("Invalid skillProvider payload");
}
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}
return session;
}
private async handleHooksInvoke(params: {
sessionId: string;
hookType: string;
input: unknown;
}): Promise<{ output?: unknown }> {
if (
!params ||
typeof params.sessionId !== "string" ||
typeof params.hookType !== "string"
) {
throw new Error("Invalid hooks invoke payload");
}
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}
const output = await session._handleHooksInvoke(params.hookType, params.input);
return { output };
}
private async handleSystemMessageTransform(params: {
sessionId: string;
sections: Record<string, { content: string }>;
}): Promise<{ sections: Record<string, { content: string }> }> {
if (
!params ||
typeof params.sessionId !== "string" ||
!params.sections ||
typeof params.sections !== "object"
) {
throw new Error("Invalid systemMessage.transform payload");
}
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}
return await session._handleSystemMessageTransform(params.sections);
}
}