diff --git a/README.md b/README.md
index 9c4cdede7e1..66b57287bdc 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes).
-Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them.
+Works with your subscriptions on Claude Code, Codex, GitHub Copilot, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them.
## "Wait, what are you selling me?"
@@ -13,10 +13,11 @@ We wanted something performant, remote-ready, and truly open. If we ever go the
## Installation
> [!WARNING]
-> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use:
+> T3 Code currently supports Codex, Claude, GitHub Copilot (Early Access), Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use:
>
> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
+> - GitHub Copilot: install [Copilot CLI](https://github.com/github/copilot-cli) and run `copilot login`
> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login`
> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login`
> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`
@@ -74,6 +75,7 @@ Full docs live in [docs/](./docs). There's no docs site yet.
- [Keeping app and server in sync](./docs/user/updating.md)
- [Source control integrations](./docs/user/source-control.md)
- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md)
+- [GitHub Copilot provider](./docs/user/providers-copilot.md)
- Linux: [run T3 Code as a background service](./docs/user/background-service.md)
Building from source? Start at [docs/internals/overview.md](./docs/internals/overview.md).
diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx
index bdddf2c4595..a782bffa951 100644
--- a/apps/mobile/src/components/ProviderIcon.tsx
+++ b/apps/mobile/src/components/ProviderIcon.tsx
@@ -49,6 +49,17 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}
+ if (props.provider === "copilot") {
+ return (
+
+
+
+ );
+ }
+
if (props.provider === "opencode") {
return (
diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts
index f15dc01975f..61391798d92 100644
--- a/apps/server/scripts/acp-mock-agent.ts
+++ b/apps/server/scripts/acp-mock-agent.ts
@@ -74,10 +74,11 @@ const permissionOptionIds = {
allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",
rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once",
};
+const permissionToolKind = process.env.T3_ACP_PERMISSION_TOOL_KIND === "edit" ? "edit" : "execute";
const sessionId = "mock-session-1";
let currentModeId = "ask";
-let currentModelId = "default";
+let currentModelId = process.env.T3_ACP_INITIAL_MODEL_ID?.trim() || "default";
let parameterizedModelPicker = false;
let currentReasoning = "medium";
let currentContext = "272k";
@@ -982,7 +983,7 @@ const program = Effect.gen(function* () {
sessionUpdate: "tool_call",
toolCallId,
title: "Terminal",
- kind: "execute",
+ kind: permissionToolKind,
status: "pending",
rawInput: {
command: ["cat", "server/package.json"],
@@ -1004,7 +1005,7 @@ const program = Effect.gen(function* () {
toolCall: {
toolCallId,
title: "`cat server/package.json`",
- kind: "execute",
+ kind: permissionToolKind,
status: "pending",
content: [
{
@@ -1041,7 +1042,7 @@ const program = Effect.gen(function* () {
sessionUpdate: "tool_call_update",
toolCallId,
title: "Terminal",
- kind: "execute",
+ kind: permissionToolKind,
status: "completed",
rawOutput: {
exitCode: 0,
diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
index 4704e7489af..37c2670bc16 100644
--- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
@@ -202,6 +202,12 @@ export interface AcpAdapterV2Flavor {
Crypto.Crypto | Scope.Scope
>;
readonly resolveModelId?: (selection: ModelSelection) => string | undefined;
+ readonly configureSession?: (input: {
+ readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"];
+ readonly startResult: AcpSessionRuntimeStartResult;
+ readonly modelSelection: ModelSelection;
+ readonly runtimePolicy: ProviderAdapterV2RuntimePolicy;
+ }) => Effect.Effect;
readonly registerExtensions?: (
context: AcpAdapterV2ExtensionContext,
) => Effect.Effect;
@@ -4597,6 +4603,14 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV
modelSelection: ModelSelection,
runtimePolicy: ProviderAdapterV2RuntimePolicy,
) {
+ if (flavor.configureSession !== undefined) {
+ return yield* flavor.configureSession({
+ runtime,
+ startResult,
+ modelSelection,
+ runtimePolicy,
+ });
+ }
const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model;
if (
requestedModel.length > 0 &&
diff --git a/apps/server/src/orchestration-v2/Adapters/CopilotAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CopilotAdapterV2.test.ts
new file mode 100644
index 00000000000..5a6ac7d70fb
--- /dev/null
+++ b/apps/server/src/orchestration-v2/Adapters/CopilotAdapterV2.test.ts
@@ -0,0 +1,168 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, describe, it } from "@effect/vitest";
+import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts";
+import * as Crypto from "effect/Crypto";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
+import { ChildProcessSpawner } from "effect/unstable/process";
+
+import { ServerConfig } from "../../config.ts";
+import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
+import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts";
+import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts";
+import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts";
+import type { AcpAdapterV2RuntimeInput } from "./AcpAdapterV2.ts";
+import {
+ COPILOT_DRIVER_KIND,
+ CopilotAdapterV2Driver,
+ makeCopilotAdapterV2,
+ makeCopilotAcpAdapterFlavor,
+ type CopilotAdapterV2Options,
+} from "./CopilotAdapterV2.ts";
+
+const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
+ prefix: "t3-copilot-v2-adapter-",
+}).pipe(Layer.provide(NodeServices.layer));
+
+const testLayer = Layer.mergeAll(NodeServices.layer, idAllocatorLayer, serverConfigLayer);
+
+function makeMockRuntime(input: {
+ readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
+ readonly mockAgentPath: string;
+}) {
+ return (runtimeInput: AcpAdapterV2RuntimeInput) =>
+ Effect.gen(function* () {
+ const context = yield* Layer.build(
+ AcpSessionRuntime.layer({
+ ...runtimeInput,
+ spawn: {
+ command: process.execPath,
+ args: [input.mockAgentPath],
+ cwd: runtimeInput.cwd,
+ env: { T3_ACP_SESSION_LIFECYCLE: "1" },
+ },
+ authMethodId: "test",
+ }).pipe(
+ Layer.provide(
+ Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
+ ),
+ ),
+ );
+ const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
+ Effect.provide(context),
+ );
+ return runtime;
+ });
+}
+
+describe("CopilotAdapterV2", () => {
+ it("is registered with Copilot schema defaults", () => {
+ assert.isTrue(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(COPILOT_DRIVER_KIND));
+ assert.equal(CopilotAdapterV2Driver.driverKind, COPILOT_DRIVER_KIND);
+ assert.deepEqual(CopilotAdapterV2Driver.defaultConfig(), {
+ enabled: true,
+ binaryPath: "copilot",
+ customModels: [],
+ });
+ });
+
+ it.effect("keeps Copilot in agent mode even for a plan interaction request", () =>
+ Effect.gen(function* () {
+ const selectedModes: Array = [];
+ const instanceId = ProviderInstanceId.make("copilot-mode-fixture");
+ const flavor = makeCopilotAcpAdapterFlavor({
+ makeRuntime: () => Effect.never,
+ } as unknown as CopilotAdapterV2Options);
+ const runtime = {
+ getConfigOptions: Effect.succeed([]),
+ getModeState: Effect.succeed({
+ currentModeId: "plan",
+ availableModes: [
+ { id: "agent", name: "Agent" },
+ { id: "plan", name: "Plan" },
+ ],
+ }),
+ setConfigOption: () => Effect.succeed({ configOptions: [] }),
+ setMode: (modeId: string) =>
+ Effect.sync(() => {
+ selectedModes.push(modeId);
+ return {};
+ }),
+ setModel: () => Effect.void,
+ } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
+ const configureSession = flavor.configureSession;
+ if (configureSession === undefined) {
+ return yield* Effect.die("Expected Copilot ACP session configuration");
+ }
+
+ yield* configureSession({
+ runtime,
+ startResult: {} as AcpSessionRuntime.AcpSessionRuntimeStartResult,
+ modelSelection: { instanceId, model: "auto" },
+ runtimePolicy: ProviderAdapterV2RuntimePolicy.make({
+ runtimeMode: "approval-required",
+ interactionMode: "plan",
+ cwd: process.cwd(),
+ }),
+ });
+
+ assert.deepEqual(selectedModes, ["agent"]);
+ assert.isFalse(flavor.capabilities.planning.emitsProposedPlan);
+ }),
+ );
+
+ it.effect("opens a Copilot V2 session through the shared ACP adapter", () =>
+ Effect.gen(function* () {
+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const idAllocator = yield* IdAllocatorV2;
+ const path = yield* Path.Path;
+ const serverConfig = yield* ServerConfig;
+ const mockAgentPath = yield* path.fromFileUrl(
+ new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
+ );
+ const instanceId = ProviderInstanceId.make("copilot-fixture");
+ const adapter = makeCopilotAdapterV2({
+ instanceId,
+ settings: {
+ enabled: true,
+ binaryPath: "copilot",
+ customModels: [],
+ },
+ environment: {},
+ childProcessSpawner,
+ crypto: yield* Crypto.Crypto,
+ fileSystem,
+ idAllocator,
+ serverConfig,
+ makeRuntime: makeMockRuntime({
+ childProcessSpawner,
+ mockAgentPath,
+ }),
+ });
+ const threadId = ThreadId.make("thread-copilot-fixture");
+ const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
+ runtimeMode: "full-access",
+ interactionMode: "plan",
+ cwd: process.cwd(),
+ });
+ const modelSelection = { instanceId, model: "default" } as const;
+ const runtime = yield* adapter.openSession({
+ threadId,
+ providerSessionId: ProviderSessionId.make("provider-session-copilot-fixture"),
+ modelSelection,
+ runtimePolicy,
+ });
+ const providerThread = yield* runtime.ensureThread({
+ threadId,
+ modelSelection,
+ runtimePolicy,
+ });
+
+ assert.equal(runtime.providerSession.driver, "copilot");
+ assert.equal(providerThread.nativeThreadRef?.nativeId, "mock-session-1");
+ }).pipe(Effect.provide(testLayer), Effect.scoped),
+ );
+});
diff --git a/apps/server/src/orchestration-v2/Adapters/CopilotAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CopilotAdapterV2.ts
new file mode 100644
index 00000000000..a8603ffb4ae
--- /dev/null
+++ b/apps/server/src/orchestration-v2/Adapters/CopilotAdapterV2.ts
@@ -0,0 +1,177 @@
+import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
+import {
+ CopilotSettings,
+ defaultInstanceIdForDriver,
+ ProviderDriverKind,
+ type OrchestrationV2ProviderCapabilities,
+} from "@t3tools/contracts";
+import * as Crypto from "effect/Crypto";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+import type * as Scope from "effect/Scope";
+import { ChildProcessSpawner } from "effect/unstable/process";
+import type * as EffectAcpErrors from "effect-acp/errors";
+
+import { ServerConfig } from "../../config.ts";
+import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts";
+import {
+ applyCopilotSessionConfiguration,
+ makeCopilotAcpRuntime,
+ resolveCopilotModeId,
+ resolveCopilotModelId,
+} from "../../provider/acp/CopilotAcpSupport.ts";
+import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
+import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts";
+import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
+import { IdAllocatorV2 } from "../IdAllocator.ts";
+import {
+ ProviderAdapterDriverCreateError,
+ type ProviderAdapterDriver,
+ type ProviderAdapterDriverCreateInput,
+} from "../ProviderAdapterDriver.ts";
+import {
+ AcpProviderCapabilitiesV2,
+ makeAcpAdapterV2,
+ type AcpAdapterV2Flavor,
+ type AcpAdapterV2RuntimeInput,
+} from "./AcpAdapterV2.ts";
+
+export const COPILOT_PROVIDER = ProviderDriverKind.make("copilot");
+export const COPILOT_DRIVER_KIND = COPILOT_PROVIDER;
+export const COPILOT_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(COPILOT_DRIVER_KIND);
+
+const DEFAULT_COPILOT_SETTINGS = Schema.decodeSync(CopilotSettings)({});
+
+export const CopilotProviderCapabilitiesV2 = {
+ ...AcpProviderCapabilitiesV2,
+ planning: {
+ ...AcpProviderCapabilitiesV2.planning,
+ emitsProposedPlan: false,
+ },
+} satisfies OrchestrationV2ProviderCapabilities;
+
+export interface CopilotAdapterV2Options {
+ readonly instanceId: Parameters[0]["instanceId"];
+ readonly settings: CopilotSettings;
+ readonly environment: NodeJS.ProcessEnv;
+ readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
+ readonly crypto: Crypto.Crypto;
+ readonly fileSystem: FileSystem.FileSystem;
+ readonly idAllocator: IdAllocatorV2["Service"];
+ readonly serverConfig: ServerConfig["Service"];
+ readonly nativeLogging?: Parameters[0]["nativeLogging"];
+ readonly makeRuntime?: (
+ input: AcpAdapterV2RuntimeInput,
+ ) => Effect.Effect<
+ AcpSessionRuntime.AcpSessionRuntime["Service"],
+ EffectAcpErrors.AcpError,
+ Crypto.Crypto | Scope.Scope
+ >;
+ readonly assertComplete?: Effect.Effect;
+}
+
+export function makeCopilotAcpAdapterFlavor(options: CopilotAdapterV2Options): AcpAdapterV2Flavor {
+ return {
+ driver: COPILOT_PROVIDER,
+ capabilities: CopilotProviderCapabilitiesV2,
+ resolveModelId: (selection) => resolveCopilotModelId(selection.model),
+ makeRuntime:
+ options.makeRuntime ??
+ ((input) =>
+ makeCopilotAcpRuntime({
+ ...input,
+ copilotSettings: options.settings,
+ environment: options.environment,
+ childProcessSpawner: options.childProcessSpawner,
+ })),
+ configureSession: ({ runtime, modelSelection, runtimePolicy }) =>
+ Effect.gen(function* () {
+ yield* applyCopilotSessionConfiguration({
+ runtime,
+ model: modelSelection.model,
+ selections: modelSelection.options,
+ mapError: ({ cause }) => cause,
+ });
+ const modeState = yield* runtime.getModeState;
+ const modeId = resolveCopilotModeId({
+ modeState,
+ interactionMode: runtimePolicy.interactionMode,
+ runtimeMode: runtimePolicy.runtimeMode,
+ });
+ if (modeId !== undefined && modeId !== modeState?.currentModeId) {
+ yield* runtime.setMode(modeId);
+ }
+ }),
+ ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }),
+ };
+}
+
+export function makeCopilotAdapterV2(options: CopilotAdapterV2Options) {
+ return makeAcpAdapterV2({
+ instanceId: options.instanceId,
+ flavor: makeCopilotAcpAdapterFlavor(options),
+ crypto: options.crypto,
+ fileSystem: options.fileSystem,
+ idAllocator: options.idAllocator,
+ serverConfig: options.serverConfig,
+ ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }),
+ });
+}
+
+export type CopilotAdapterV2DriverEnv =
+ | ChildProcessSpawner.ChildProcessSpawner
+ | Crypto.Crypto
+ | FileSystem.FileSystem
+ | IdAllocatorV2
+ | ProviderEventLoggers
+ | ServerConfig;
+
+export const CopilotAdapterV2Driver: ProviderAdapterDriver<
+ CopilotSettings,
+ CopilotAdapterV2DriverEnv
+> = {
+ driverKind: COPILOT_DRIVER_KIND,
+ configSchema: CopilotSettings,
+ defaultConfig: (): CopilotSettings => DEFAULT_COPILOT_SETTINGS,
+ create: Effect.fn("CopilotAdapterV2Driver.create")(
+ function* (input: ProviderAdapterDriverCreateInput) {
+ const hostEnvironment = yield* HostProcessEnvironment;
+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const crypto = yield* Crypto.Crypto;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const idAllocator = yield* IdAllocatorV2;
+ const providerEventLoggers = yield* ProviderEventLoggers;
+ const serverConfig = yield* ServerConfig;
+ const makeNativeLogger = yield* makeAcpNativeLoggerFactory();
+ return makeCopilotAdapterV2({
+ instanceId: input.instanceId,
+ settings: { ...input.config, enabled: input.enabled },
+ environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment),
+ childProcessSpawner,
+ crypto,
+ fileSystem,
+ idAllocator,
+ serverConfig,
+ nativeLogging: (threadId) =>
+ makeNativeLogger({
+ nativeEventLogger: providerEventLoggers.native,
+ provider: COPILOT_PROVIDER,
+ threadId,
+ }),
+ });
+ },
+ (effect, input) =>
+ effect.pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderAdapterDriverCreateError({
+ driver: COPILOT_DRIVER_KIND,
+ instanceId: input.instanceId,
+ detail: "Failed to create GitHub Copilot ACP adapter.",
+ cause,
+ }),
+ ),
+ ),
+ ),
+};
diff --git a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts
index 1ef37a2bfa0..8e27742736e 100644
--- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts
+++ b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts
@@ -9,6 +9,10 @@ import {
type ClaudeAdapterV2DriverEnv,
} from "./Adapters/ClaudeAdapterV2.ts";
import { CodexAdapterV2Driver, type CodexAdapterV2DriverEnv } from "./Adapters/CodexAdapterV2.ts";
+import {
+ CopilotAdapterV2Driver,
+ type CopilotAdapterV2DriverEnv,
+} from "./Adapters/CopilotAdapterV2.ts";
import {
CursorAdapterV2Driver,
type CursorAdapterV2DriverEnv,
@@ -24,6 +28,7 @@ export type BuiltInProviderAdapterDriversV2Env =
| AcpRegistryAdapterV2DriverEnv
| ClaudeAdapterV2DriverEnv
| CodexAdapterV2DriverEnv
+ | CopilotAdapterV2DriverEnv
| CursorAdapterV2DriverEnv
| GrokAdapterV2DriverEnv
| OpenCodeAdapterV2DriverEnv;
@@ -33,6 +38,7 @@ export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray<
> = [
CodexAdapterV2Driver,
ClaudeAdapterV2Driver,
+ CopilotAdapterV2Driver,
CursorAdapterV2Driver,
OpenCodeAdapterV2Driver,
GrokAdapterV2Driver,
diff --git a/apps/server/src/provider/Drivers/CopilotDriver.test.ts b/apps/server/src/provider/Drivers/CopilotDriver.test.ts
new file mode 100644
index 00000000000..3b3f91aa9ef
--- /dev/null
+++ b/apps/server/src/provider/Drivers/CopilotDriver.test.ts
@@ -0,0 +1,95 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import {
+ ProviderDriverKind,
+ ProviderInstanceId,
+ type ProviderInstanceConfigMap,
+} from "@t3tools/contracts";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Stream from "effect/Stream";
+import { HttpClient, HttpClientResponse } from "effect/unstable/http";
+
+import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
+import { ServerConfig } from "../../config.ts";
+import { ServerSettingsService } from "../../serverSettings.ts";
+import { makeProviderInstanceRegistry } from "../Layers/ProviderInstanceRegistryLive.ts";
+import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
+import { ProviderOrchestrationAdapterInfrastructureLive } from "../Layers/ProviderOrchestrationAdapterInfrastructure.ts";
+import { CopilotDriver } from "./CopilotDriver.ts";
+
+const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
+const BackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPolicy)({
+ reportClientActivity: () => Effect.void,
+ removeRpcClient: () => Effect.void,
+ reportHostPowerState: () => Effect.void,
+ snapshot: Effect.succeed({
+ hostPower: {
+ source: "unknown",
+ idle: "unknown",
+ idleSeconds: null,
+ locked: "unknown",
+ suspended: false,
+ onBattery: "unknown",
+ lowPowerMode: "unknown",
+ thermalState: "unknown",
+ stale: true,
+ updatedAt: TEST_EPOCH,
+ },
+ leases: [],
+ activeForegroundLeaseCount: 0,
+ activeScopeKeys: [],
+ shouldRunOpportunisticWork: true,
+ updatedAt: TEST_EPOCH,
+ }),
+ streamChanges: Stream.empty,
+ hasDemand: () => Effect.succeed(true),
+ shouldRunScopeWork: () => Effect.succeed(true),
+ shouldRunOpportunisticWork: Effect.succeed(true),
+});
+const TestHttpClient = Layer.succeed(
+ HttpClient.HttpClient,
+ HttpClient.make((request) =>
+ Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "1.0.79" }))),
+ ),
+);
+const baseLayer = ServerConfig.layerTest(process.cwd(), {
+ prefix: "copilot-driver-test",
+}).pipe(
+ Layer.provideMerge(NodeServices.layer),
+ Layer.provideMerge(BackgroundPolicyAlwaysRun),
+ Layer.provideMerge(ServerSettingsService.layerTest()),
+ Layer.provideMerge(TestHttpClient),
+ Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
+);
+const testLayer = ProviderOrchestrationAdapterInfrastructureLive.pipe(
+ Layer.provideMerge(baseLayer),
+);
+
+describe("CopilotDriver", () => {
+ it.effect("registers a disabled Copilot instance through the provider driver SPI", () =>
+ Effect.gen(function* () {
+ const instanceId = ProviderInstanceId.make("copilot");
+ const configMap: ProviderInstanceConfigMap = {
+ [instanceId]: {
+ driver: ProviderDriverKind.make("copilot"),
+ enabled: false,
+ config: {
+ enabled: false,
+ binaryPath: "copilot",
+ customModels: [],
+ },
+ },
+ };
+ const { registry } = yield* makeProviderInstanceRegistry({
+ drivers: [CopilotDriver],
+ configMap,
+ });
+ const instance = yield* registry.getInstance(instanceId);
+ expect(instance?.driverKind).toBe("copilot");
+ expect(instance?.orchestrationAdapter.driver).toBe("copilot");
+ expect((yield* instance!.snapshot.getSnapshot).displayName).toBe("GitHub Copilot");
+ }).pipe(Effect.provide(testLayer)),
+ );
+});
diff --git a/apps/server/src/provider/Drivers/CopilotDriver.ts b/apps/server/src/provider/Drivers/CopilotDriver.ts
new file mode 100644
index 00000000000..12ec75e2dde
--- /dev/null
+++ b/apps/server/src/provider/Drivers/CopilotDriver.ts
@@ -0,0 +1,174 @@
+import { CopilotSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
+import * as Crypto from "effect/Crypto";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import { HttpClient } from "effect/unstable/http";
+import { ChildProcessSpawner } from "effect/unstable/process";
+
+import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
+import { ServerConfig } from "../../config.ts";
+import {
+ CopilotAdapterV2Driver,
+ type CopilotAdapterV2DriverEnv,
+} from "../../orchestration-v2/Adapters/CopilotAdapterV2.ts";
+import { ServerSettingsService } from "../../serverSettings.ts";
+import { makeCopilotTextGeneration } from "../../textGeneration/CopilotTextGeneration.ts";
+import { ProviderDriverError } from "../Errors.ts";
+import {
+ buildInitialCopilotProviderSnapshot,
+ checkCopilotProviderStatus,
+ enrichCopilotSnapshot,
+} from "../Layers/CopilotProvider.ts";
+import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
+import {
+ defaultProviderContinuationIdentity,
+ type ProviderDriver,
+ type ProviderInstance,
+} from "../ProviderDriver.ts";
+import type { ServerProviderDraft } from "../providerSnapshot.ts";
+import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
+import {
+ makePackageManagedProviderMaintenanceResolver,
+ resolveProviderMaintenanceCapabilitiesEffect,
+} from "../providerMaintenance.ts";
+import {
+ haveProviderSnapshotSettingsChanged,
+ makeProviderSnapshotSettingsSource,
+ type ProviderSnapshotSettings,
+} from "../providerUpdateSettings.ts";
+
+const decodeCopilotSettings = Schema.decodeSync(CopilotSettings);
+const DRIVER_KIND = ProviderDriverKind.make("copilot");
+const UPDATE = makePackageManagedProviderMaintenanceResolver({
+ provider: DRIVER_KIND,
+ npmPackageName: "@github/copilot",
+ homebrewFormula: null,
+ nativeUpdate: null,
+});
+
+export type CopilotDriverEnv =
+ | CopilotAdapterV2DriverEnv
+ | BackgroundPolicy.BackgroundPolicy
+ | ChildProcessSpawner.ChildProcessSpawner
+ | Crypto.Crypto
+ | FileSystem.FileSystem
+ | HttpClient.HttpClient
+ | Path.Path
+ | ServerConfig
+ | ServerSettingsService;
+
+const withInstanceIdentity =
+ (input: {
+ readonly instanceId: ProviderInstance["instanceId"];
+ readonly displayName: string | undefined;
+ readonly accentColor: string | undefined;
+ readonly continuationGroupKey: string;
+ }) =>
+ (snapshot: ServerProviderDraft): ServerProvider => ({
+ ...snapshot,
+ instanceId: input.instanceId,
+ driver: DRIVER_KIND,
+ ...(input.displayName ? { displayName: input.displayName } : {}),
+ ...(input.accentColor ? { accentColor: input.accentColor } : {}),
+ continuation: { groupKey: input.continuationGroupKey },
+ });
+
+export const CopilotDriver: ProviderDriver = {
+ driverKind: DRIVER_KIND,
+ metadata: {
+ displayName: "GitHub Copilot",
+ supportsMultipleInstances: true,
+ },
+ configSchema: CopilotSettings,
+ defaultConfig: (): CopilotSettings => decodeCopilotSettings({}),
+ create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
+ Effect.gen(function* () {
+ const crypto = yield* Crypto.Crypto;
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const httpClient = yield* HttpClient.HttpClient;
+ const serverSettings = yield* ServerSettingsService;
+ const processEnv = mergeProviderInstanceEnvironment(environment);
+ const continuationIdentity = defaultProviderContinuationIdentity({
+ driverKind: DRIVER_KIND,
+ instanceId,
+ });
+ const stampIdentity = withInstanceIdentity({
+ instanceId,
+ displayName,
+ accentColor,
+ continuationGroupKey: continuationIdentity.continuationKey,
+ });
+ const effectiveConfig = { ...config, enabled } satisfies CopilotSettings;
+ const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
+ binaryPath: effectiveConfig.binaryPath,
+ env: processEnv,
+ });
+
+ const orchestrationAdapter = yield* CopilotAdapterV2Driver.create({
+ instanceId,
+ displayName,
+ accentColor,
+ environment,
+ enabled,
+ config,
+ }).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderDriverError({
+ driver: DRIVER_KIND,
+ instanceId,
+ detail: "Failed to build GitHub Copilot orchestration adapter.",
+ cause,
+ }),
+ ),
+ );
+ const textGeneration = yield* makeCopilotTextGeneration(effectiveConfig, processEnv);
+ const checkProvider = checkCopilotProviderStatus(effectiveConfig, processEnv).pipe(
+ Effect.map(stampIdentity),
+ Effect.provideService(Crypto.Crypto, crypto),
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ );
+ const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
+ const snapshot = yield* makeManagedServerProvider>({
+ maintenanceCapabilities,
+ getSettings: snapshotSettings.getSettings,
+ streamSettings: snapshotSettings.streamSettings,
+ haveSettingsChanged: haveProviderSnapshotSettingsChanged,
+ initialSnapshot: (settings) =>
+ buildInitialCopilotProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)),
+ checkProvider,
+ enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
+ enrichCopilotSnapshot({
+ snapshot: currentSnapshot,
+ maintenanceCapabilities,
+ enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
+ publishSnapshot,
+ httpClient,
+ }),
+ }).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderDriverError({
+ driver: DRIVER_KIND,
+ instanceId,
+ detail: "Failed to build GitHub Copilot snapshot.",
+ cause,
+ }),
+ ),
+ );
+
+ return {
+ instanceId,
+ driverKind: DRIVER_KIND,
+ continuationIdentity,
+ displayName,
+ accentColor,
+ enabled,
+ snapshot,
+ orchestrationAdapter,
+ textGeneration,
+ } satisfies ProviderInstance;
+ }),
+};
diff --git a/apps/server/src/provider/Layers/CopilotProvider.test.ts b/apps/server/src/provider/Layers/CopilotProvider.test.ts
new file mode 100644
index 00000000000..c70e771d82c
--- /dev/null
+++ b/apps/server/src/provider/Layers/CopilotProvider.test.ts
@@ -0,0 +1,84 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+
+import { CopilotSettings } from "@t3tools/contracts";
+import {
+ buildCopilotDiscoveredModels,
+ buildCopilotModelCapabilities,
+ buildCopilotProviderModels,
+ buildInitialCopilotProviderSnapshot,
+ checkCopilotProviderStatus,
+ isCopilotAuthFailure,
+} from "./CopilotProvider.ts";
+
+const decodeSettings = Schema.decodeSync(CopilotSettings);
+
+describe("CopilotProvider helpers", () => {
+ it.effect("presents an enabled Early Access provider with in-session model changes", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* buildInitialCopilotProviderSnapshot(decodeSettings({}));
+ expect(snapshot.displayName).toBe("GitHub Copilot");
+ expect(snapshot.badgeLabel).toBe("Early Access");
+ expect(snapshot.showInteractionModeToggle).toBe(false);
+ expect(snapshot.requiresNewThreadForModelChange).toBe(false);
+ }),
+ );
+
+ it("discovers ACP models and exposes reasoning effort", () => {
+ const configOptions = [
+ {
+ id: "reasoning_effort",
+ name: "Reasoning effort",
+ type: "select",
+ currentValue: "medium",
+ options: [
+ { value: "low", name: "Low" },
+ { value: "medium", name: "Medium" },
+ { value: "high", name: "High" },
+ ],
+ },
+ ] as const;
+ const capabilities = buildCopilotModelCapabilities(configOptions);
+ expect(capabilities.optionDescriptors?.[0]?.id).toBe("reasoningEffort");
+ expect(
+ buildCopilotDiscoveredModels(
+ {
+ currentModelId: "gpt-5.4",
+ availableModels: [{ modelId: "gpt-5.4", name: "GPT-5.4" }],
+ },
+ configOptions,
+ ),
+ ).toMatchObject([{ slug: "gpt-5.4", name: "GPT-5.4" }]);
+ });
+
+ it("preserves the auto fallback when ACP reports no models", () => {
+ expect(buildCopilotProviderModels([], [])).toMatchObject([
+ { slug: "auto", name: "Auto", isCustom: false },
+ ]);
+ });
+
+ it("recognizes auth failures without treating generic startup errors as logged out", () => {
+ expect(isCopilotAuthFailure(new Error("Not authenticated. Run copilot login."))).toBe(true);
+ expect(isCopilotAuthFailure({ cause: { message: "GH_TOKEN is invalid" } })).toBe(true);
+ expect(isCopilotAuthFailure(new Error("Unexpected token in JSON"))).toBe(false);
+ expect(isCopilotAuthFailure(new Error("ACP transport closed unexpectedly"))).toBe(false);
+ });
+});
+
+it.layer(NodeServices.layer)("checkCopilotProviderStatus", (it) => {
+ it.effect("clearly reports a missing Copilot CLI", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* checkCopilotProviderStatus(
+ decodeSettings({
+ binaryPath: "/definitely/not/installed/copilot",
+ }),
+ );
+ expect(snapshot.installed).toBe(false);
+ expect(snapshot.status).toBe("error");
+ expect(snapshot.auth.status).toBe("unknown");
+ expect(snapshot.message).toMatch(/not installed|not on PATH/);
+ }),
+ );
+});
diff --git a/apps/server/src/provider/Layers/CopilotProvider.ts b/apps/server/src/provider/Layers/CopilotProvider.ts
new file mode 100644
index 00000000000..7074d9515c3
--- /dev/null
+++ b/apps/server/src/provider/Layers/CopilotProvider.ts
@@ -0,0 +1,357 @@
+import {
+ type CopilotSettings,
+ type ModelCapabilities,
+ type ServerProvider,
+ type ServerProviderModel,
+} from "@t3tools/contracts";
+import { createModelCapabilities } from "@t3tools/shared/model";
+import { causeErrorTag } from "@t3tools/shared/observability";
+import * as Cause from "effect/Cause";
+import * as Crypto from "effect/Crypto";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
+import * as Option from "effect/Option";
+import * as Predicate from "effect/Predicate";
+import * as Result from "effect/Result";
+import { HttpClient } from "effect/unstable/http";
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
+import type * as EffectAcpSchema from "effect-acp/schema";
+
+import { makeCopilotAcpRuntime, resolveCopilotModelId } from "../acp/CopilotAcpSupport.ts";
+import {
+ buildSelectOptionDescriptor,
+ buildServerProvider,
+ isCommandMissingCause,
+ parseGenericCliVersion,
+ providerModelsFromSettings,
+ spawnAndCollect,
+ type ServerProviderDraft,
+} from "../providerSnapshot.ts";
+import {
+ enrichProviderSnapshotWithVersionAdvisory,
+ type ProviderMaintenanceCapabilities,
+} from "../providerMaintenance.ts";
+import { resolveSpawnCommand } from "@t3tools/shared/shell";
+
+const COPILOT_PRESENTATION = {
+ displayName: "GitHub Copilot",
+ badgeLabel: "Early Access",
+ showInteractionModeToggle: false,
+ requiresNewThreadForModelChange: false,
+} as const;
+const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [] });
+const VERSION_PROBE_TIMEOUT_MS = 4_000;
+const ACP_DISCOVERY_TIMEOUT_MS = 15_000;
+
+const FALLBACK_MODELS: ReadonlyArray = [
+ {
+ slug: "auto",
+ name: "Auto",
+ isCustom: false,
+ capabilities: EMPTY_CAPABILITIES,
+ },
+];
+
+export function buildCopilotProviderModels(
+ customModels: ReadonlyArray | undefined,
+ builtInModels: ReadonlyArray = FALLBACK_MODELS,
+): ReadonlyArray {
+ return providerModelsFromSettings(
+ builtInModels.length > 0 ? builtInModels : FALLBACK_MODELS,
+ customModels ?? [],
+ EMPTY_CAPABILITIES,
+ );
+}
+
+function selectOptions(
+ option: EffectAcpSchema.SessionConfigOption | undefined,
+): ReadonlyArray<{ value: string; label: string; isDefault?: boolean }> {
+ if (!option || option.type !== "select") return [];
+ return option.options.flatMap((entry) =>
+ "value" in entry
+ ? [
+ {
+ value: entry.value,
+ label: entry.name,
+ ...(entry.value === option.currentValue ? { isDefault: true } : {}),
+ },
+ ]
+ : entry.options.map((nested) => ({
+ value: nested.value,
+ label: nested.name,
+ ...(nested.value === option.currentValue ? { isDefault: true } : {}),
+ })),
+ );
+}
+
+export function buildCopilotModelCapabilities(
+ configOptions: ReadonlyArray | null | undefined,
+): ModelCapabilities {
+ const reasoning = configOptions?.find((option) => option.id === "reasoning_effort");
+ const options = selectOptions(reasoning);
+ return createModelCapabilities({
+ optionDescriptors:
+ options.length === 0
+ ? []
+ : [
+ buildSelectOptionDescriptor({
+ id: "reasoningEffort",
+ label: reasoning?.name.trim() || "Reasoning",
+ options,
+ }),
+ ],
+ });
+}
+
+export function buildCopilotDiscoveredModels(
+ modelState: EffectAcpSchema.SessionModelState | null | undefined,
+ configOptions?: ReadonlyArray,
+): ReadonlyArray {
+ if (!modelState || modelState.availableModels.length === 0) return [];
+ const capabilities = buildCopilotModelCapabilities(configOptions);
+ const seen = new Set();
+ return modelState.availableModels.flatMap((model) => {
+ const slug = resolveCopilotModelId(model.modelId);
+ if (seen.has(slug)) return [];
+ seen.add(slug);
+ return [
+ {
+ slug,
+ name: model.name.trim() || slug,
+ isCustom: false,
+ capabilities,
+ } satisfies ServerProviderModel,
+ ];
+ });
+}
+
+export function isCopilotAuthFailure(value: unknown): boolean {
+ const seen = new Set();
+ const collect = (current: unknown, depth: number): string => {
+ if (depth > 6 || current === null || current === undefined || seen.has(current)) return "";
+ seen.add(current);
+ if (typeof current === "string") return current;
+ if (current instanceof Error) return `${current.message} ${collect(current.cause, depth + 1)}`;
+ if (!Predicate.isObject(current)) return String(current);
+ return ["message", "detail", "errorMessage", "cause", "error", "data"]
+ .map((key) => collect(current[key], depth + 1))
+ .join(" ");
+ };
+ return /(?:not authenticated|unauthenticated|authentication|unauthorized|log(?:ged)? in|login|credential|(?:access|auth(?:entication)?|refresh|id|bearer|gh|github|copilot)[ _-]?token|gh auth)/i.test(
+ collect(value, 0),
+ );
+}
+
+export function buildInitialCopilotProviderSnapshot(
+ settings: CopilotSettings,
+): Effect.Effect {
+ return Effect.gen(function* () {
+ const checkedAt = DateTime.formatIso(yield* DateTime.now);
+ const models = buildCopilotProviderModels(settings.customModels);
+ return buildServerProvider({
+ presentation: COPILOT_PRESENTATION,
+ enabled: settings.enabled,
+ checkedAt,
+ models,
+ probe: settings.enabled
+ ? {
+ installed: true,
+ version: null,
+ status: "warning",
+ auth: { status: "unknown" },
+ message: "Checking GitHub Copilot CLI availability...",
+ }
+ : {
+ installed: false,
+ version: null,
+ status: "warning",
+ auth: { status: "unknown" },
+ message: "GitHub Copilot is disabled in T3 Code settings.",
+ },
+ });
+ });
+}
+
+export const discoverCopilotModelsViaAcp = (
+ settings: CopilotSettings,
+ environment: NodeJS.ProcessEnv = process.env,
+) =>
+ Effect.gen(function* () {
+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const acp = yield* makeCopilotAcpRuntime({
+ copilotSettings: settings,
+ environment,
+ childProcessSpawner,
+ cwd: process.cwd(),
+ clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
+ });
+ const started = yield* acp.start();
+ return buildCopilotDiscoveredModels(
+ started.sessionSetupResult.models,
+ yield* acp.getConfigOptions,
+ );
+ }).pipe(Effect.scoped);
+
+const runCopilotVersionCommand = (
+ settings: CopilotSettings,
+ environment: NodeJS.ProcessEnv = process.env,
+) =>
+ Effect.gen(function* () {
+ const command = settings.binaryPath || "copilot";
+ const spawnCommand = yield* resolveSpawnCommand(command, ["--version", "--no-auto-update"], {
+ env: environment,
+ });
+ return yield* spawnAndCollect(
+ command,
+ ChildProcess.make(spawnCommand.command, spawnCommand.args, {
+ env: environment,
+ shell: spawnCommand.shell,
+ }),
+ );
+ });
+
+export const checkCopilotProviderStatus = Effect.fn("checkCopilotProviderStatus")(function* (
+ settings: CopilotSettings,
+ environment: NodeJS.ProcessEnv = process.env,
+): Effect.fn.Return<
+ ServerProviderDraft,
+ never,
+ ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto
+> {
+ const checkedAt = DateTime.formatIso(yield* DateTime.now);
+ const fallbackModels = buildCopilotProviderModels(settings.customModels);
+ if (!settings.enabled) return yield* buildInitialCopilotProviderSnapshot(settings);
+
+ const versionResult = yield* runCopilotVersionCommand(settings, environment).pipe(
+ Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS),
+ Effect.result,
+ );
+ if (Result.isFailure(versionResult)) {
+ const missing = isCommandMissingCause(versionResult.failure);
+ return buildServerProvider({
+ presentation: COPILOT_PRESENTATION,
+ enabled: true,
+ checkedAt,
+ models: fallbackModels,
+ probe: {
+ installed: !missing,
+ version: null,
+ status: "error",
+ auth: { status: "unknown" },
+ message: missing
+ ? "GitHub Copilot CLI (`copilot`) is not installed or not on PATH."
+ : "Failed to execute the GitHub Copilot CLI health check.",
+ },
+ });
+ }
+ if (Option.isNone(versionResult.success)) {
+ return buildServerProvider({
+ presentation: COPILOT_PRESENTATION,
+ enabled: true,
+ checkedAt,
+ models: fallbackModels,
+ probe: {
+ installed: true,
+ version: null,
+ status: "error",
+ auth: { status: "unknown" },
+ message: "GitHub Copilot CLI timed out while running `copilot --version`.",
+ },
+ });
+ }
+
+ const output = versionResult.success.value;
+ const version = parseGenericCliVersion(`${output.stdout}\n${output.stderr}`);
+ if (output.code !== 0) {
+ return buildServerProvider({
+ presentation: COPILOT_PRESENTATION,
+ enabled: true,
+ checkedAt,
+ models: fallbackModels,
+ probe: {
+ installed: true,
+ version,
+ status: "error",
+ auth: { status: "unknown" },
+ message: "GitHub Copilot CLI is installed but failed to run.",
+ },
+ });
+ }
+
+ const discoveryExit = yield* discoverCopilotModelsViaAcp(settings, environment).pipe(
+ Effect.timeoutOption(ACP_DISCOVERY_TIMEOUT_MS),
+ Effect.exit,
+ );
+ if (Exit.isFailure(discoveryExit)) {
+ const failure = Cause.squash(discoveryExit.cause);
+ const unauthenticated = isCopilotAuthFailure(failure);
+ yield* Effect.logWarning("GitHub Copilot ACP discovery failed", {
+ errorTag: causeErrorTag(discoveryExit.cause),
+ });
+ return buildServerProvider({
+ presentation: COPILOT_PRESENTATION,
+ enabled: true,
+ checkedAt,
+ models: fallbackModels,
+ probe: {
+ installed: true,
+ version,
+ status: "error",
+ auth: { status: unauthenticated ? "unauthenticated" : "unknown" },
+ message: unauthenticated
+ ? "GitHub Copilot CLI is not authenticated. Run `copilot login` and try again."
+ : "GitHub Copilot ACP session startup failed. Check server logs for details.",
+ },
+ });
+ }
+ if (Option.isNone(discoveryExit.value)) {
+ return buildServerProvider({
+ presentation: COPILOT_PRESENTATION,
+ enabled: true,
+ checkedAt,
+ models: fallbackModels,
+ probe: {
+ installed: true,
+ version,
+ status: "error",
+ auth: { status: "unknown" },
+ message: `GitHub Copilot ACP session startup timed out after ${ACP_DISCOVERY_TIMEOUT_MS}ms.`,
+ },
+ });
+ }
+
+ const discoveredModels = discoveryExit.value.value;
+ return buildServerProvider({
+ presentation: COPILOT_PRESENTATION,
+ enabled: true,
+ checkedAt,
+ models: buildCopilotProviderModels(settings.customModels, discoveredModels),
+ probe: {
+ installed: true,
+ version,
+ status: "ready",
+ auth: { status: "authenticated" },
+ },
+ });
+});
+
+export const enrichCopilotSnapshot = (input: {
+ readonly snapshot: ServerProvider;
+ readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
+ readonly enableProviderUpdateChecks?: boolean;
+ readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect;
+ readonly httpClient: HttpClient.HttpClient;
+}): Effect.Effect =>
+ enrichProviderSnapshotWithVersionAdvisory(input.snapshot, input.maintenanceCapabilities, {
+ enableProviderUpdateChecks: input.enableProviderUpdateChecks,
+ }).pipe(
+ Effect.provideService(HttpClient.HttpClient, input.httpClient),
+ Effect.flatMap(input.publishSnapshot),
+ Effect.catchCause((cause) =>
+ Effect.logWarning("GitHub Copilot version advisory enrichment failed", {
+ errorTag: causeErrorTag(cause),
+ }),
+ ),
+ Effect.asVoid,
+ );
diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts
index 65422bde7da..41c984e2170 100644
--- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts
+++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts
@@ -10,7 +10,7 @@
*
* 2. **Many drivers, one registry** — the "all drivers slice" describe
* block below configures one instance of every shipped driver
- * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single
+ * (`codex`, `claudeAgent`, `copilot`, `cursor`, `grok`, `opencode`) in a single
* `ProviderInstanceConfigMap` and asserts the registry boots them all
* without cross-contamination. This proves the driver SPI is uniform
* across every provider — any driver plugs into the registry through
@@ -18,7 +18,7 @@
*
* Every instance in these tests is configured with `enabled: false` so the
* provider-status checks short-circuit to pending/disabled snapshots
- * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode`
+ * without trying to spawn real `codex` / `claude` / `copilot` / `agent` / `grok` / `opencode`
* binaries. That keeps the assertions focused on registry routing
* behaviour rather than the runtime details of each provider.
*/
@@ -27,6 +27,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import {
type ClaudeSettings,
type CodexSettings,
+ type CopilotSettings,
type CursorSettings,
type GrokSettings,
type OpenCodeSettings,
@@ -46,6 +47,7 @@ import { ServerSettingsService } from "../../serverSettings.ts";
import type { BuiltInDriversEnv } from "../builtInDrivers.ts";
import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";
import { CodexDriver, type CodexDriverEnv } from "../Drivers/CodexDriver.ts";
+import { CopilotDriver } from "../Drivers/CopilotDriver.ts";
import { CursorDriver } from "../Drivers/CursorDriver.ts";
import { GrokDriver } from "../Drivers/GrokDriver.ts";
import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts";
@@ -117,6 +119,13 @@ const makeCursorConfig = (overrides: Partial): CursorSettings =>
...overrides,
});
+const makeCopilotConfig = (overrides: Partial): CopilotSettings => ({
+ enabled: false,
+ binaryPath: "copilot",
+ customModels: [],
+ ...overrides,
+});
+
const makeGrokConfig = (overrides: Partial): GrokSettings => ({
enabled: false,
binaryPath: "grok",
@@ -146,7 +155,6 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(TestHttpClientLive),
- Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
);
const testLayer = ProviderOrchestrationAdapterInfrastructureLive.pipe(
@@ -289,7 +297,6 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(TestHttpClientLive),
- Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
);
const testLayer = ProviderOrchestrationAdapterInfrastructureLive.pipe(
@@ -301,12 +308,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
const codexId = ProviderInstanceId.make("codex_default");
const claudeId = ProviderInstanceId.make("claude_default");
const cursorId = ProviderInstanceId.make("cursor_default");
+ const copilotId = ProviderInstanceId.make("copilot_default");
const grokId = ProviderInstanceId.make("grok_default");
const openCodeId = ProviderInstanceId.make("opencode_default");
const codexDriverKind = ProviderDriverKind.make("codex");
const claudeDriverKind = ProviderDriverKind.make("claudeAgent");
const cursorDriverKind = ProviderDriverKind.make("cursor");
+ const copilotDriverKind = ProviderDriverKind.make("copilot");
const grokDriverKind = ProviderDriverKind.make("grok");
const openCodeDriverKind = ProviderDriverKind.make("opencode");
@@ -332,6 +341,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
enabled: false,
config: makeCursorConfig({}),
},
+ [copilotId]: {
+ driver: copilotDriverKind,
+ displayName: "GitHub Copilot",
+ enabled: false,
+ config: makeCopilotConfig({}),
+ },
[grokId]: {
driver: grokDriverKind,
displayName: "Grok",
@@ -347,7 +362,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
};
const { registry } = yield* makeProviderInstanceRegistry({
- drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver],
+ drivers: [
+ CodexDriver,
+ ClaudeDriver,
+ CopilotDriver,
+ CursorDriver,
+ GrokDriver,
+ OpenCodeDriver,
+ ],
configMap,
});
@@ -357,9 +379,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
expect(unavailable).toEqual([]);
const instances = yield* registry.listInstances;
- expect(instances).toHaveLength(5);
+ expect(instances).toHaveLength(6);
expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual(
- [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(),
+ [codexId, claudeId, copilotId, cursorId, grokId, openCodeId].toSorted(),
);
// Instance lookup by id resolves each instance to its own bundle —
@@ -368,16 +390,19 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
const codex = yield* registry.getInstance(codexId);
const claude = yield* registry.getInstance(claudeId);
const cursor = yield* registry.getInstance(cursorId);
+ const copilot = yield* registry.getInstance(copilotId);
const grok = yield* registry.getInstance(grokId);
const openCode = yield* registry.getInstance(openCodeId);
expect(codex?.driverKind).toBe(codexDriverKind);
expect(claude?.driverKind).toBe(claudeDriverKind);
expect(cursor?.driverKind).toBe(cursorDriverKind);
+ expect(copilot?.driverKind).toBe(copilotDriverKind);
expect(grok?.driverKind).toBe(grokDriverKind);
expect(openCode?.driverKind).toBe(openCodeDriverKind);
expect(codex?.displayName).toBe("Codex");
expect(claude?.displayName).toBe("Claude");
expect(cursor?.displayName).toBe("Cursor");
+ expect(copilot?.displayName).toBe("GitHub Copilot");
expect(grok?.displayName).toBe("Grok");
expect(openCode?.displayName).toBe("OpenCode");
@@ -390,6 +415,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
codex!.orchestrationAdapter,
claude!.orchestrationAdapter,
cursor!.orchestrationAdapter,
+ copilot!.orchestrationAdapter,
grok!.orchestrationAdapter,
openCode!.orchestrationAdapter,
];
@@ -398,6 +424,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
codex!.textGeneration,
claude!.textGeneration,
cursor!.textGeneration,
+ copilot!.textGeneration,
grok!.textGeneration,
openCode!.textGeneration,
];
@@ -406,6 +433,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
codex!.snapshot,
claude!.snapshot,
cursor!.snapshot,
+ copilot!.snapshot,
grok!.snapshot,
openCode!.snapshot,
];
@@ -437,6 +465,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
`${cursorDriverKind}:instance:${cursorId}`,
);
+ const copilotSnapshot = yield* copilot!.snapshot.getSnapshot;
+ expect(copilotSnapshot.instanceId).toBe(copilotId);
+ expect(copilotSnapshot.driver).toBe(copilotDriverKind);
+ expect(copilotSnapshot.enabled).toBe(false);
+ expect(copilotSnapshot.continuation?.groupKey).toBe(
+ `${copilotDriverKind}:instance:${copilotId}`,
+ );
+
const grokSnapshot = yield* grok!.snapshot.getSnapshot;
expect(grokSnapshot.instanceId).toBe(grokId);
expect(grokSnapshot.driver).toBe(grokDriverKind);
diff --git a/apps/server/src/provider/acp/CopilotAcpSupport.test.ts b/apps/server/src/provider/acp/CopilotAcpSupport.test.ts
new file mode 100644
index 00000000000..b350349d923
--- /dev/null
+++ b/apps/server/src/provider/acp/CopilotAcpSupport.test.ts
@@ -0,0 +1,198 @@
+import { describe, expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+
+import {
+ applyCopilotSessionConfiguration,
+ buildCopilotAcpSpawnInput,
+ COPILOT_AUTH_METHOD_ID,
+ resolveCopilotModeId,
+ resolveCopilotModelId,
+} from "./CopilotAcpSupport.ts";
+
+describe("CopilotAcpSupport", () => {
+ it("uses the authentication method advertised by the official CLI", () => {
+ expect(COPILOT_AUTH_METHOD_ID).toBe("copilot-login");
+ });
+
+ it("builds the official Copilot ACP stdio launch and preserves environment", () => {
+ const spawn = buildCopilotAcpSpawnInput(
+ { binaryPath: "/usr/local/bin/copilot" },
+ "/tmp/project",
+ {
+ COPILOT_GITHUB_TOKEN: "secret",
+ COPILOT_HOME: "/tmp/copilot-home",
+ },
+ );
+ expect(spawn).toEqual({
+ command: "/usr/local/bin/copilot",
+ args: ["--acp", "--stdio", "--no-auto-update"],
+ cwd: "/tmp/project",
+ env: {
+ COPILOT_GITHUB_TOKEN: "secret",
+ COPILOT_HOME: "/tmp/copilot-home",
+ },
+ });
+ });
+
+ it("normalizes model ids and keeps Copilot in Agent mode", () => {
+ expect(resolveCopilotModelId(" gpt-5.4 ")).toBe("gpt-5.4");
+ expect(resolveCopilotModelId(undefined)).toBe("auto");
+ const modeState = {
+ currentModeId: "agent",
+ availableModes: [
+ { id: "agent", name: "Agent" },
+ { id: "plan", name: "Plan" },
+ { id: "autopilot", name: "Autopilot" },
+ ],
+ };
+ expect(
+ resolveCopilotModeId({
+ modeState,
+ interactionMode: "plan",
+ runtimeMode: "approval-required",
+ }),
+ ).toBe("agent");
+ expect(
+ resolveCopilotModeId({
+ modeState,
+ interactionMode: "default",
+ runtimeMode: "approval-required",
+ }),
+ ).toBe("agent");
+ expect(
+ resolveCopilotModeId({
+ modeState,
+ interactionMode: "default",
+ runtimeMode: "full-access",
+ }),
+ ).toBe("agent");
+ });
+
+ it.effect("sets model and reasoning effort without mutating allow_all", () =>
+ Effect.gen(function* () {
+ const calls: Array = [];
+ yield* applyCopilotSessionConfiguration({
+ runtime: {
+ getConfigOptions: Effect.succeed([
+ {
+ id: "model",
+ name: "Model",
+ type: "select",
+ currentValue: "gpt-5",
+ options: [{ value: "gpt-5.4", name: "GPT-5.4" }],
+ },
+ {
+ id: "reasoning_effort",
+ name: "Reasoning effort",
+ type: "select",
+ currentValue: "medium",
+ options: [{ value: "high", name: "High" }],
+ },
+ {
+ id: "allow_all",
+ name: "Allow all",
+ type: "boolean",
+ currentValue: false,
+ },
+ ]),
+ setModel: (model) =>
+ Effect.sync(() => {
+ calls.push(["model", model]);
+ }),
+ setConfigOption: (id, value) =>
+ Effect.sync(() => {
+ calls.push([id, value]);
+ return { configOptions: [] };
+ }),
+ },
+ model: "gpt-5.4",
+ selections: [{ id: "reasoningEffort", value: "high" }],
+ mapError: (context) => context.cause,
+ });
+ expect(calls).toEqual([
+ ["model", "gpt-5.4"],
+ ["reasoning_effort", "high"],
+ ]);
+ }),
+ );
+
+ it.effect("resets a resumed concrete model to the negotiated automatic model", () =>
+ Effect.gen(function* () {
+ const models: Array = [];
+ yield* applyCopilotSessionConfiguration({
+ runtime: {
+ getConfigOptions: Effect.succeed([
+ {
+ id: "model",
+ name: "Model",
+ type: "select",
+ currentValue: "gpt-5.4",
+ options: [
+ { value: "default", name: "Auto" },
+ { value: "gpt-5.4", name: "GPT-5.4" },
+ ],
+ },
+ ]),
+ setModel: (model) =>
+ Effect.sync(() => {
+ models.push(model);
+ }),
+ setConfigOption: () => Effect.succeed({ configOptions: [] }),
+ },
+ model: "auto",
+ selections: [],
+ mapError: (context) => context.cause,
+ });
+ expect(models).toEqual(["default"]);
+ }),
+ );
+
+ it.effect("refreshes model-dependent options before applying reasoning effort", () =>
+ Effect.gen(function* () {
+ const calls: Array = [];
+ let modelConfigured = false;
+ yield* applyCopilotSessionConfiguration({
+ runtime: {
+ getConfigOptions: Effect.sync(() =>
+ modelConfigured
+ ? [
+ {
+ id: "reasoning_effort",
+ name: "Reasoning effort",
+ type: "select" as const,
+ currentValue: "medium",
+ options: [{ value: "high", name: "High" }],
+ },
+ ]
+ : [
+ {
+ id: "model",
+ name: "Model",
+ type: "select" as const,
+ currentValue: "default",
+ options: [{ value: "gpt-5.4", name: "GPT-5.4" }],
+ },
+ ],
+ ),
+ setModel: (model) =>
+ Effect.sync(() => {
+ calls.push(["model", model]);
+ modelConfigured = true;
+ }),
+ setConfigOption: (id, value) =>
+ Effect.sync(() => {
+ calls.push([id, String(value)]);
+ return { configOptions: [] };
+ }),
+ },
+ model: "gpt-5.4",
+ selections: [{ id: "reasoningEffort", value: "high" }],
+ mapError: (context) => context.cause,
+ });
+ expect(calls).toEqual([
+ ["model", "gpt-5.4"],
+ ["reasoning_effort", "high"],
+ ]);
+ }),
+ );
+});
diff --git a/apps/server/src/provider/acp/CopilotAcpSupport.ts b/apps/server/src/provider/acp/CopilotAcpSupport.ts
new file mode 100644
index 00000000000..17816e4fc82
--- /dev/null
+++ b/apps/server/src/provider/acp/CopilotAcpSupport.ts
@@ -0,0 +1,176 @@
+import {
+ type CopilotSettings,
+ type ProviderInteractionMode,
+ type ProviderOptionSelection,
+ type RuntimeMode,
+} from "@t3tools/contracts";
+import * as Crypto from "effect/Crypto";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Scope from "effect/Scope";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+import type * as EffectAcpErrors from "effect-acp/errors";
+import type * as EffectAcpSchema from "effect-acp/schema";
+
+import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
+import type { AcpSessionModeState } from "./AcpRuntimeModel.ts";
+
+export const COPILOT_AUTH_METHOD_ID = "copilot-login";
+const DEFAULT_COPILOT_MODEL = "auto";
+
+type CopilotAcpRuntimeSettings = Pick;
+
+export interface CopilotAcpRuntimeInput extends Omit<
+ AcpSessionRuntime.AcpSessionRuntimeOptions,
+ "authMethodId" | "clientCapabilities" | "spawn"
+> {
+ readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
+ readonly copilotSettings: CopilotAcpRuntimeSettings | null | undefined;
+ readonly environment?: NodeJS.ProcessEnv;
+}
+
+export function buildCopilotAcpSpawnInput(
+ copilotSettings: CopilotAcpRuntimeSettings | null | undefined,
+ cwd: string,
+ environment?: NodeJS.ProcessEnv,
+): AcpSessionRuntime.AcpSpawnInput {
+ return {
+ command: copilotSettings?.binaryPath || "copilot",
+ args: ["--acp", "--stdio", "--no-auto-update"],
+ cwd,
+ ...(environment ? { env: environment } : {}),
+ };
+}
+
+export const makeCopilotAcpRuntime = (
+ input: CopilotAcpRuntimeInput,
+): Effect.Effect<
+ AcpSessionRuntime.AcpSessionRuntime["Service"],
+ EffectAcpErrors.AcpError,
+ Crypto.Crypto | Scope.Scope
+> =>
+ Effect.gen(function* () {
+ const acpContext = yield* Layer.build(
+ AcpSessionRuntime.layer({
+ ...input,
+ spawn: buildCopilotAcpSpawnInput(input.copilotSettings, input.cwd, input.environment),
+ authMethodId: COPILOT_AUTH_METHOD_ID,
+ }).pipe(
+ Layer.provide(
+ Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
+ ),
+ ),
+ );
+ return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
+ Effect.provide(acpContext),
+ );
+ });
+
+export function resolveCopilotModelId(model: string | null | undefined): string {
+ return model?.trim() || DEFAULT_COPILOT_MODEL;
+}
+
+export function currentCopilotModelIdFromSessionSetup(
+ sessionSetupResult:
+ | EffectAcpSchema.LoadSessionResponse
+ | EffectAcpSchema.NewSessionResponse
+ | EffectAcpSchema.ResumeSessionResponse,
+): string | undefined {
+ return sessionSetupResult.models?.currentModelId?.trim() || undefined;
+}
+
+function selectedString(
+ selections: ReadonlyArray | null | undefined,
+ id: string,
+): string | undefined {
+ const value = selections?.find((selection) => selection.id === id)?.value;
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
+}
+
+function hasConfigOption(
+ options: ReadonlyArray,
+ id: string,
+): boolean {
+ return options.some((option) => option.id === id);
+}
+
+function resolveCopilotSessionModelId(
+ requestedModel: string,
+ options: ReadonlyArray,
+): string {
+ const model = resolveCopilotModelId(requestedModel);
+ if (model !== "auto" && model !== "default") return model;
+
+ const modelOption = options.find((option) => option.id === "model");
+ if (!modelOption || modelOption.type !== "select") return model;
+ const choices = modelOption.options.flatMap((option) =>
+ "value" in option ? [option] : option.options,
+ );
+ const aliases = model === "auto" ? ["auto", "default"] : ["default", "auto"];
+ return (
+ aliases.flatMap((alias) => choices.filter((choice) => choice.value === alias))[0]?.value ??
+ choices.find((choice) => /^(?:auto(?:matic)?|default)$/i.test(choice.name.trim()))?.value ??
+ model
+ );
+}
+
+export function applyCopilotSessionConfiguration(input: {
+ readonly runtime: Pick<
+ AcpSessionRuntime.AcpSessionRuntime["Service"],
+ "getConfigOptions" | "setConfigOption" | "setModel"
+ >;
+ readonly model: string | null | undefined;
+ readonly selections: ReadonlyArray | null | undefined;
+ readonly mapError: (context: {
+ readonly cause: EffectAcpErrors.AcpError;
+ readonly method: "session/set_config_option";
+ readonly configId: string;
+ }) => E;
+}): Effect.Effect {
+ return Effect.gen(function* () {
+ let configOptions = yield* input.runtime.getConfigOptions;
+ if (input.model?.trim()) {
+ const model = resolveCopilotSessionModelId(input.model, configOptions);
+ yield* input.runtime
+ .setModel(model)
+ .pipe(
+ Effect.mapError((cause) =>
+ input.mapError({ cause, method: "session/set_config_option", configId: "model" }),
+ ),
+ );
+ configOptions = yield* input.runtime.getConfigOptions;
+ }
+
+ const reasoningEffort = selectedString(input.selections, "reasoningEffort");
+ if (reasoningEffort && hasConfigOption(configOptions, "reasoning_effort")) {
+ yield* input.runtime.setConfigOption("reasoning_effort", reasoningEffort).pipe(
+ Effect.mapError((cause) =>
+ input.mapError({
+ cause,
+ method: "session/set_config_option",
+ configId: "reasoning_effort",
+ }),
+ ),
+ );
+ }
+ });
+}
+
+function findMode(
+ modeState: AcpSessionModeState | undefined,
+ names: ReadonlyArray,
+): string | undefined {
+ if (!modeState) return undefined;
+ const aliases = new Set(names.map((name) => name.toLowerCase()));
+ return modeState.availableModes.find(
+ (mode) => aliases.has(mode.id.toLowerCase()) || aliases.has(mode.name.toLowerCase()),
+ )?.id;
+}
+
+export function resolveCopilotModeId(input: {
+ readonly modeState: AcpSessionModeState | undefined;
+ readonly interactionMode: ProviderInteractionMode | undefined;
+ readonly runtimeMode: RuntimeMode;
+}): string | undefined {
+ return findMode(input.modeState, ["agent"]) ?? input.modeState?.currentModeId;
+}
diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts
index bbff99705d2..fbc3328dea7 100644
--- a/apps/server/src/provider/builtInDrivers.ts
+++ b/apps/server/src/provider/builtInDrivers.ts
@@ -23,6 +23,7 @@
import { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./Drivers/AcpRegistryDriver.ts";
import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";
import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
+import { CopilotDriver, type CopilotDriverEnv } from "./Drivers/CopilotDriver.ts";
import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";
import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";
import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts";
@@ -37,6 +38,7 @@ export type BuiltInDriversEnv =
| AcpRegistryDriverEnv
| ClaudeDriverEnv
| CodexDriverEnv
+ | CopilotDriverEnv
| CursorDriverEnv
| GrokDriverEnv
| OpenCodeDriverEnv;
@@ -49,6 +51,7 @@ export type BuiltInDriversEnv =
export const BUILT_IN_DRIVERS: ReadonlyArray> = [
CodexDriver,
ClaudeDriver,
+ CopilotDriver,
CursorDriver,
GrokDriver,
OpenCodeDriver,
diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts
new file mode 100644
index 00000000000..ad72a400b7c
--- /dev/null
+++ b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts
@@ -0,0 +1,144 @@
+// @effect-diagnostics nodeBuiltinImport:off
+import * as NodeFS from "node:fs";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+import * as NodeURL from "node:url";
+
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { it } from "@effect/vitest";
+import { CopilotSettings, ProviderInstanceId } from "@t3tools/contracts";
+import { createModelSelection } from "@t3tools/shared/model";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Schema from "effect/Schema";
+import { expect } from "vite-plus/test";
+
+import * as ServerConfig from "../config.ts";
+import { makeCopilotTextGeneration } from "./CopilotTextGeneration.ts";
+import * as TextGeneration from "./TextGeneration.ts";
+
+const decodeCopilotSettings = Schema.decodeSync(CopilotSettings);
+const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
+const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts");
+
+function shellSingleQuote(value: string): string {
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
+}
+
+const CopilotTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
+ prefix: "t3code-copilot-text-generation-test-",
+}).pipe(Layer.provideMerge(NodeServices.layer));
+
+function makeAcpCopilotWrapper(dir: string, env: Record): string {
+ const binDir = NodePath.join(dir, "bin");
+ const copilotPath = NodePath.join(binDir, "copilot");
+ NodeFS.mkdirSync(binDir, { recursive: true });
+ NodeFS.writeFileSync(
+ copilotPath,
+ [
+ "#!/bin/sh",
+ ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`),
+ 'if [ "$1" != "--acp" ] || [ "$2" != "--stdio" ] || [ "$3" != "--no-auto-update" ]; then',
+ ' printf "%s\\n" "unexpected args: $*" >&2',
+ " exit 11",
+ "fi",
+ `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)}`,
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+ NodeFS.chmodSync(copilotPath, 0o755);
+ return copilotPath;
+}
+
+function withFakeAcpCopilot(
+ env: Record,
+ effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect,
+) {
+ return Effect.gen(function* () {
+ const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-copilot-text-acp-"));
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ NodeFS.rmSync(tempDir, { recursive: true, force: true });
+ }),
+ );
+ const binaryPath = makeAcpCopilotWrapper(tempDir, env);
+ const config = decodeCopilotSettings({ binaryPath });
+ const textGeneration = yield* makeCopilotTextGeneration(config);
+ return yield* effectFn(textGeneration);
+ }).pipe(Effect.scoped);
+}
+
+function readJsonRpcRequests(
+ filePath: string,
+): ReadonlyArray<{ readonly method?: string; readonly params?: Record }> {
+ return NodeFS.readFileSync(filePath, "utf8")
+ .trim()
+ .split("\n")
+ .filter((line) => line.length > 0)
+ .map((line) => JSON.parse(line) as { method?: string; params?: Record });
+}
+
+it.layer(CopilotTextGenerationTestLayer)("CopilotTextGeneration", (it) => {
+ it.effect("uses Copilot ACP for structured commit messages", () => {
+ const requestLogDir = NodeFS.mkdtempSync(
+ NodePath.join(NodeOS.tmpdir(), "t3code-copilot-text-log-"),
+ );
+ const requestLogPath = NodePath.join(requestLogDir, "requests.ndjson");
+
+ return withFakeAcpCopilot(
+ {
+ T3_ACP_AUTH_METHOD_ID: "copilot-login",
+ T3_ACP_INITIAL_MODEL_ID: "gpt-5.4",
+ T3_ACP_REQUEST_LOG_PATH: requestLogPath,
+ T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
+ subject: "Add GitHub Copilot provider",
+ body: "Run Copilot through the shared ACP runtime.",
+ }),
+ },
+ (textGeneration) =>
+ Effect.gen(function* () {
+ const generated = yield* textGeneration.generateCommitMessage({
+ cwd: process.cwd(),
+ branch: "feature/copilot",
+ stagedSummary: "M apps/server/src/provider/Drivers/CopilotDriver.ts",
+ stagedPatch: "diff --git a/.../CopilotDriver.ts b/.../CopilotDriver.ts",
+ modelSelection: createModelSelection(ProviderInstanceId.make("copilot"), "auto"),
+ });
+
+ expect(generated).toEqual({
+ subject: "Add GitHub Copilot provider",
+ body: "Run Copilot through the shared ACP runtime.",
+ });
+ const requests = readJsonRpcRequests(requestLogPath);
+ expect(
+ requests.some(
+ (request) =>
+ request.method === "session/set_config_option" &&
+ request.params?.configId === "model" &&
+ request.params?.value === "default",
+ ),
+ ).toBe(true);
+ }),
+ );
+ });
+
+ it.effect("extracts structured output from conversational text", () =>
+ withFakeAcpCopilot(
+ {
+ T3_ACP_AUTH_METHOD_ID: "copilot-login",
+ T3_ACP_PROMPT_RESPONSE_TEXT:
+ "Here is the title:\n" + JSON.stringify({ title: "Investigate Copilot ACP" }) + "\nDone.",
+ },
+ (textGeneration) =>
+ Effect.gen(function* () {
+ const generated = yield* textGeneration.generateThreadTitle({
+ cwd: process.cwd(),
+ message: "the copilot provider needs a title",
+ modelSelection: createModelSelection(ProviderInstanceId.make("copilot"), "auto"),
+ });
+ expect(generated.title).toBe("Investigate Copilot ACP");
+ }),
+ ),
+ );
+});
diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts
new file mode 100644
index 00000000000..b3910b90082
--- /dev/null
+++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts
@@ -0,0 +1,263 @@
+import { type CopilotSettings, type ModelSelection, TextGenerationError } from "@t3tools/contracts";
+import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
+import { extractJsonObject } from "@t3tools/shared/schemaJson";
+import * as Crypto from "effect/Crypto";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Ref from "effect/Ref";
+import * as Schema from "effect/Schema";
+import { ChildProcessSpawner } from "effect/unstable/process";
+import type * as EffectAcpErrors from "effect-acp/errors";
+
+import {
+ applyCopilotSessionConfiguration,
+ makeCopilotAcpRuntime,
+ resolveCopilotModeId,
+} from "../provider/acp/CopilotAcpSupport.ts";
+import * as TextGeneration from "./TextGeneration.ts";
+import {
+ buildBranchNamePrompt,
+ buildCommitMessagePrompt,
+ buildPrContentPrompt,
+ buildThreadTitlePrompt,
+} from "./TextGenerationPrompts.ts";
+import {
+ sanitizeCommitSubject,
+ sanitizePrTitle,
+ sanitizeThreadTitle,
+} from "./TextGenerationUtils.ts";
+
+const COPILOT_TIMEOUT_MS = 180_000;
+const isTextGenerationError = Schema.is(TextGenerationError);
+
+export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")(function* (
+ copilotSettings: CopilotSettings,
+ environment: NodeJS.ProcessEnv = process.env,
+) {
+ const crypto = yield* Crypto.Crypto;
+ const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+
+ const runCopilotJson = ({
+ operation,
+ cwd,
+ prompt,
+ outputSchemaJson,
+ modelSelection,
+ }: {
+ operation:
+ | "generateCommitMessage"
+ | "generatePrContent"
+ | "generateBranchName"
+ | "generateThreadTitle";
+ cwd: string;
+ prompt: string;
+ outputSchemaJson: S;
+ modelSelection: ModelSelection;
+ }): Effect.Effect =>
+ Effect.gen(function* () {
+ if (!copilotSettings.enabled) {
+ return yield* new TextGenerationError({
+ operation,
+ detail: "GitHub Copilot is disabled in T3 Code settings.",
+ });
+ }
+
+ const outputRef = yield* Ref.make("");
+ const runtime = yield* makeCopilotAcpRuntime({
+ copilotSettings,
+ environment,
+ childProcessSpawner: commandSpawner,
+ cwd,
+ clientInfo: { name: "t3-code-git-text", version: "0.0.0" },
+ }).pipe(Effect.provideService(Crypto.Crypto, crypto));
+
+ yield* runtime.handleSessionUpdate((notification) => {
+ const update = notification.update;
+ if (update.sessionUpdate !== "agent_message_chunk") {
+ return Effect.void;
+ }
+ const content = update.content;
+ if (content.type !== "text") {
+ return Effect.void;
+ }
+ return Ref.update(outputRef, (current) => current + content.text);
+ });
+
+ const promptResult = yield* Effect.gen(function* () {
+ yield* runtime.start();
+ yield* applyCopilotSessionConfiguration({
+ runtime,
+ model: modelSelection.model,
+ selections: modelSelection.options,
+ mapError: ({ cause }) =>
+ new TextGenerationError({
+ operation,
+ detail: "Failed to configure GitHub Copilot ACP text generation.",
+ cause,
+ }),
+ });
+ const modeId = resolveCopilotModeId({
+ modeState: yield* runtime.getModeState,
+ interactionMode: "default",
+ runtimeMode: "approval-required",
+ });
+ if (modeId) {
+ yield* runtime.setMode(modeId);
+ }
+
+ return yield* runtime.prompt({
+ prompt: [{ type: "text", text: prompt }],
+ });
+ }).pipe(
+ Effect.timeoutOption(COPILOT_TIMEOUT_MS),
+ Effect.flatMap(
+ Option.match({
+ onNone: () =>
+ Effect.fail(
+ new TextGenerationError({
+ operation,
+ detail: "GitHub Copilot ACP request timed out.",
+ }),
+ ),
+ onSome: (value) => Effect.succeed(value),
+ }),
+ ),
+ Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) =>
+ isTextGenerationError(cause)
+ ? cause
+ : new TextGenerationError({
+ operation,
+ detail: "GitHub Copilot ACP request failed.",
+ cause,
+ }),
+ ),
+ );
+
+ const trimmed = (yield* Ref.get(outputRef)).trim();
+ if (!trimmed) {
+ return yield* new TextGenerationError({
+ operation,
+ detail:
+ promptResult.stopReason === "cancelled"
+ ? "GitHub Copilot ACP request was cancelled."
+ : "GitHub Copilot returned empty output.",
+ });
+ }
+
+ const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson));
+ return yield* decodeOutput(extractJsonObject(trimmed)).pipe(
+ Effect.catchTags({
+ SchemaError: (cause) =>
+ Effect.fail(
+ new TextGenerationError({
+ operation,
+ detail: "GitHub Copilot returned invalid structured output.",
+ cause,
+ }),
+ ),
+ }),
+ );
+ }).pipe(
+ Effect.mapError((cause) =>
+ isTextGenerationError(cause)
+ ? cause
+ : new TextGenerationError({
+ operation,
+ detail: "GitHub Copilot ACP text generation failed.",
+ cause,
+ }),
+ ),
+ Effect.scoped,
+ );
+
+ const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] =
+ Effect.fn("CopilotTextGeneration.generateCommitMessage")(function* (input) {
+ const { prompt, outputSchema } = buildCommitMessagePrompt({
+ branch: input.branch,
+ stagedSummary: input.stagedSummary,
+ stagedPatch: input.stagedPatch,
+ includeBranch: input.includeBranch === true,
+ policy: input.policy,
+ });
+ const generated = yield* runCopilotJson({
+ operation: "generateCommitMessage",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+ return {
+ subject: sanitizeCommitSubject(generated.subject),
+ body: generated.body.trim(),
+ ...("branch" in generated && typeof generated.branch === "string"
+ ? { branch: sanitizeFeatureBranchName(generated.branch) }
+ : {}),
+ };
+ });
+
+ const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] =
+ Effect.fn("CopilotTextGeneration.generatePrContent")(function* (input) {
+ const { prompt, outputSchema } = buildPrContentPrompt({
+ baseBranch: input.baseBranch,
+ headBranch: input.headBranch,
+ commitSummary: input.commitSummary,
+ diffSummary: input.diffSummary,
+ diffPatch: input.diffPatch,
+ policy: input.policy,
+ changeRequestTemplate: input.changeRequestTemplate,
+ });
+ const generated = yield* runCopilotJson({
+ operation: "generatePrContent",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+ return {
+ title: sanitizePrTitle(generated.title),
+ body: generated.body.trim(),
+ };
+ });
+
+ const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] =
+ Effect.fn("CopilotTextGeneration.generateBranchName")(function* (input) {
+ const { prompt, outputSchema } = buildBranchNamePrompt({
+ message: input.message,
+ attachments: input.attachments,
+ });
+ const generated = yield* runCopilotJson({
+ operation: "generateBranchName",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+ return { branch: sanitizeBranchFragment(generated.branch) };
+ });
+
+ const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] =
+ Effect.fn("CopilotTextGeneration.generateThreadTitle")(function* (input) {
+ const { prompt, outputSchema } = buildThreadTitlePrompt({
+ message: input.message,
+ previousTitle: input.previousTitle,
+ attachments: input.attachments,
+ });
+ const generated = yield* runCopilotJson({
+ operation: "generateThreadTitle",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+ return {
+ title: sanitizeThreadTitle(generated.title),
+ } satisfies TextGeneration.ThreadTitleGenerationResult;
+ });
+
+ return {
+ generateCommitMessage,
+ generatePrContent,
+ generateBranchName,
+ generateThreadTitle,
+ } satisfies TextGeneration.TextGeneration["Service"];
+});
diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts
index 66b7ccd465f..da05be4fb96 100644
--- a/apps/server/src/textGeneration/TextGeneration.ts
+++ b/apps/server/src/textGeneration/TextGeneration.ts
@@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance
import type { ProviderInstance } from "../provider/ProviderDriver.ts";
import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts";
-export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode";
+export type TextGenerationProvider =
+ | "codex"
+ | "claudeAgent"
+ | "copilot"
+ | "cursor"
+ | "grok"
+ | "opencode";
export interface CommitMessageGenerationInput {
cwd: string;
diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts
index 842c616fe1f..cc31f89c253 100644
--- a/apps/web/src/components/chat/providerIconUtils.ts
+++ b/apps/web/src/components/chat/providerIconUtils.ts
@@ -1,10 +1,19 @@
import { ProviderDriverKind } from "@t3tools/contracts";
-import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons";
+import {
+ ClaudeAI,
+ CursorIcon,
+ GithubCopilotIcon,
+ GrokIcon,
+ Icon,
+ OpenAI,
+ OpenCodeIcon,
+} from "../Icons";
import { PROVIDER_OPTIONS } from "../../session-logic";
export const PROVIDER_ICON_BY_PROVIDER: Partial> = {
[ProviderDriverKind.make("codex")]: OpenAI,
[ProviderDriverKind.make("claudeAgent")]: ClaudeAI,
+ [ProviderDriverKind.make("copilot")]: GithubCopilotIcon,
[ProviderDriverKind.make("opencode")]: OpenCodeIcon,
[ProviderDriverKind.make("cursor")]: CursorIcon,
[ProviderDriverKind.make("grok")]: GrokIcon,
diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx
index 260d895ff2b..4ac53f586bf 100644
--- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx
+++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx
@@ -14,7 +14,7 @@ import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hook
import { cn } from "../../lib/utils";
import { normalizeProviderAccentColor } from "../../providerInstances";
import { Button } from "../ui/button";
-import { Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";
+import { Gemini, PiAgentIcon, type Icon } from "../Icons";
import {
Dialog,
DialogDescription,
@@ -78,11 +78,6 @@ interface ComingSoonDriverOption {
}
const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [
- {
- value: ProviderDriverKind.make("githubCopilot"),
- label: "Github Copilot",
- icon: GithubCopilotIcon,
- },
{
value: ProviderDriverKind.make("gemini"),
label: "Gemini",
diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts
index 45ae5a79921..234acff6995 100644
--- a/apps/web/src/components/settings/providerDriverMeta.ts
+++ b/apps/web/src/components/settings/providerDriverMeta.ts
@@ -2,6 +2,7 @@ import {
AcpRegistrySettings,
ClaudeSettings,
CodexSettings,
+ CopilotSettings,
CursorSettings,
GrokSettings,
OpenCodeSettings,
@@ -12,6 +13,7 @@ import {
ACPRegistryIcon,
ClaudeAI,
CursorIcon,
+ GithubCopilotIcon,
GrokIcon,
type Icon,
OpenAI,
@@ -67,6 +69,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] =
icon: ClaudeAI,
settingsSchema: ClaudeSettings,
},
+ {
+ value: ProviderDriverKind.make("copilot"),
+ label: "GitHub Copilot",
+ icon: GithubCopilotIcon,
+ badgeLabel: "Early Access",
+ settingsSchema: CopilotSettings,
+ },
{
value: ProviderDriverKind.make("cursor"),
label: "Cursor",
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index a12b2db048b..5ffb7faa740 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -31,6 +31,12 @@ export const PROVIDER_OPTIONS: Array<{
}> = [
{ value: ProviderDriverKind.make("codex"), label: "Codex", available: true },
{ value: ProviderDriverKind.make("claudeAgent"), label: "Claude", available: true },
+ {
+ value: ProviderDriverKind.make("copilot"),
+ label: "GitHub Copilot",
+ available: true,
+ pickerSidebarBadge: "new",
+ },
{
value: ProviderDriverKind.make("opencode"),
label: "OpenCode",
diff --git a/docs/README.md b/docs/README.md
index adaebd5bee0..17435ab3fd4 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -13,7 +13,8 @@
- [Keeping app and server in sync](./user/updating.md)
- [Source control integrations](./user/source-control.md)
- [Background service (Linux)](./user/background-service.md)
-- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md)
+- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) ·
+ [GitHub Copilot](./user/providers-copilot.md)
Mobile app: [apps/mobile/README.md](../apps/mobile/README.md)
diff --git a/docs/internals/providers.md b/docs/internals/providers.md
index a309d70f03d..4b9f7b50785 100644
--- a/docs/internals/providers.md
+++ b/docs/internals/providers.md
@@ -7,15 +7,17 @@ orchestration layer does not know which one is behind a thread.
## Built-in drivers
-[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries:
-
-| Driver kind | Driver source |
-| ------------- | --------------------------------------- |
-| `codex` | [`Drivers/CodexDriver.ts`][codex] |
-| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] |
-| `cursor` | [`Drivers/CursorDriver.ts`][cursor] |
-| `grok` | [`Drivers/GrokDriver.ts`][grok] |
-| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] |
+[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with seven entries:
+
+| Driver kind | Driver source |
+| ------------- | ---------------------------------------------- |
+| `codex` | [`Drivers/CodexDriver.ts`][codex] |
+| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] |
+| `copilot` | [`Drivers/CopilotDriver.ts`][copilot] |
+| `cursor` | [`Drivers/CursorDriver.ts`][cursor] |
+| `grok` | [`Drivers/GrokDriver.ts`][grok] |
+| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] |
+| `acpRegistry` | [`Drivers/AcpRegistryDriver.ts`][acp-registry] |
Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an
adapter in a child scope. Adapter implementations live beside them in
@@ -78,9 +80,11 @@ when a request opens (approval) or user input is requested, via
[drivers]: ../../apps/server/src/provider/builtInDrivers.ts
[codex]: ../../apps/server/src/provider/Drivers/CodexDriver.ts
[claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts
+[copilot]: ../../apps/server/src/provider/Drivers/CopilotDriver.ts
[cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts
[grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts
[opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts
+[acp-registry]: ../../apps/server/src/provider/Drivers/AcpRegistryDriver.ts
[adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts
[instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts
[registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts
diff --git a/docs/user/providers-copilot.md b/docs/user/providers-copilot.md
new file mode 100644
index 00000000000..77d75b5b698
--- /dev/null
+++ b/docs/user/providers-copilot.md
@@ -0,0 +1,40 @@
+# GitHub Copilot
+
+T3 Code connects directly to the official GitHub Copilot CLI through its ACP server. It does not
+route Copilot through OpenCode.
+
+## Install
+
+Install the CLI globally:
+
+```bash
+npm install -g @github/copilot
+```
+
+Then authenticate:
+
+```bash
+copilot login
+```
+
+Keep the provider's Binary path set to `copilot` unless the executable is installed elsewhere.
+
+## Token Authentication
+
+The Copilot CLI also accepts authentication from environment variables. Add one of these to the
+Copilot provider instance in Settings:
+
+```text
+COPILOT_GITHUB_TOKEN
+GH_TOKEN
+GITHUB_TOKEN
+```
+
+Mark tokens as sensitive. The CLI decides credential precedence and can also use a stored
+`copilot login` or GitHub CLI (`gh auth`) session. T3 Code forwards the provider environment,
+including `COPILOT_HOME`, without interpreting credentials.
+
+## Early Access
+
+GitHub currently describes ACP support as a public preview. T3 Code labels the provider Early
+Access because CLI behavior may change while that preview evolves.
diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts
index 1c8c16a0638..226b5d3248c 100644
--- a/packages/contracts/src/model.ts
+++ b/packages/contracts/src/model.ts
@@ -130,6 +130,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type;
const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex");
const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
+const COPILOT_DRIVER_KIND = ProviderDriverKind.make("copilot");
const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry");
const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");
@@ -152,6 +153,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial>
[CODEX_DRIVER_KIND]: "Codex",
[CLAUDE_DRIVER_KIND]: "Claude",
[CURSOR_DRIVER_KIND]: "Cursor",
+ [COPILOT_DRIVER_KIND]: "GitHub Copilot",
[GROK_DRIVER_KIND]: "Grok",
[ACP_REGISTRY_DRIVER_KIND]: "ACP Registry",
[OPENCODE_DRIVER_KIND]: "OpenCode",
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 34f82d10fa7..80445c25ec0 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -131,6 +131,11 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
// Legacy `providers` struct is still hydrated with its per-driver defaults
// so existing call sites keep working through the migration.
expect(decoded.providers.codex.enabled).toBe(true);
+ expect(decoded.providers.copilot).toEqual({
+ enabled: true,
+ binaryPath: "copilot",
+ customModels: [],
+ });
});
it("decodes a multi-instance map mixing first-party and fork drivers", () => {
@@ -293,6 +298,9 @@ describe("ServerSettingsPatch string normalization", () => {
homePath: " ~/.codex ",
launchArgs: " --strict-config --enable foo ",
},
+ copilot: {
+ binaryPath: " /opt/homebrew/bin/copilot ",
+ },
},
providerInstances: {
codex_personal: {
@@ -309,6 +317,7 @@ describe("ServerSettingsPatch string normalization", () => {
expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex");
expect(patch.providers?.codex?.homePath).toBe("~/.codex");
expect(patch.providers?.codex?.launchArgs).toBe("--strict-config --enable foo");
+ expect(patch.providers?.copilot?.binaryPath).toBe("/opt/homebrew/bin/copilot");
expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe(
"codex",
);
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 3572a241514..d68415ff686 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -456,6 +456,30 @@ export const AcpRegistrySettings = makeProviderSettingsSchema(
);
export type AcpRegistrySettings = typeof AcpRegistrySettings.Type;
+export const CopilotSettings = makeProviderSettingsSchema(
+ {
+ enabled: Schema.Boolean.pipe(
+ Schema.withDecodingDefault(Effect.succeed(true)),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ binaryPath: makeBinaryPathSetting("copilot").pipe(
+ Schema.annotateKey({
+ title: "Binary path",
+ description: "Path to the GitHub Copilot CLI binary.",
+ providerSettingsForm: { placeholder: "copilot", clearWhenEmpty: "omit" },
+ }),
+ ),
+ customModels: Schema.Array(Schema.String).pipe(
+ Schema.withDecodingDefault(Effect.succeed([])),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ },
+ {
+ order: ["binaryPath"],
+ },
+);
+export type CopilotSettings = typeof CopilotSettings.Type;
+
export const OpenCodeSettings = makeProviderSettingsSchema(
{
enabled: Schema.Boolean.pipe(
@@ -633,6 +657,7 @@ export const ServerSettings = Schema.Struct({
providers: Schema.Struct({
codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
+ copilot: CopilotSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
@@ -729,6 +754,12 @@ const GrokSettingsPatch = Schema.Struct({
customModels: Schema.optionalKey(Schema.Array(Schema.String)),
});
+const CopilotSettingsPatch = Schema.Struct({
+ enabled: Schema.optionalKey(Schema.Boolean),
+ binaryPath: Schema.optionalKey(TrimmedString),
+ customModels: Schema.optionalKey(Schema.Array(Schema.String)),
+});
+
const OpenCodeSettingsPatch = Schema.Struct({
enabled: Schema.optionalKey(Schema.Boolean),
binaryPath: Schema.optionalKey(TrimmedString),
@@ -774,6 +805,7 @@ export const ServerSettingsPatch = Schema.Struct({
Schema.Struct({
codex: Schema.optionalKey(CodexSettingsPatch),
claudeAgent: Schema.optionalKey(ClaudeSettingsPatch),
+ copilot: Schema.optionalKey(CopilotSettingsPatch),
cursor: Schema.optionalKey(CursorSettingsPatch),
grok: Schema.optionalKey(GrokSettingsPatch),
opencode: Schema.optionalKey(OpenCodeSettingsPatch),