diff --git a/apps/server/package.json b/apps/server/package.json index d1e5f5f3..6c846e00 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -26,7 +26,8 @@ "@anthropic-ai/claude-agent-sdk": "^0.2.77", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", - "@pierre/diffs": "1.1.13", + "@github/copilot-sdk": "^0.2.1", + "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", "node-pty": "^1.1.0", "open": "^10.1.0", diff --git a/apps/server/src/doctor.ts b/apps/server/src/doctor.ts index 15d77de7..b10c6af6 100644 --- a/apps/server/src/doctor.ts +++ b/apps/server/src/doctor.ts @@ -10,6 +10,7 @@ import { Effect } from "effect"; import { Command } from "effect/unstable/cli"; import { + checkCopilotProviderStatus, checkCodexProviderStatus, checkClaudeProviderStatus, } from "./provider/Layers/ProviderHealth"; @@ -32,6 +33,7 @@ const AUTH_LABELS: Record = { const PROVIDER_LABELS: Record = { codex: "Codex (OpenAI)", claudeAgent: "Claude Code", + copilot: "GitHub Copilot", }; function printStatus(status: ServerProviderStatus): void { @@ -65,9 +67,12 @@ const doctorProgram = Effect.gen(function* () { console.log(""); console.log("Checking provider health..."); - const statuses = yield* Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], { - concurrency: "unbounded", - }); + const statuses = yield* Effect.all( + [checkCodexProviderStatus, checkClaudeProviderStatus, checkCopilotProviderStatus], + { + concurrency: "unbounded", + }, + ); for (const status of statuses) { printStatus(status); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts new file mode 100644 index 00000000..d70010d3 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -0,0 +1,1168 @@ +import { + ApprovalRequestId, + type CanonicalItemType, + type CanonicalRequestType, + EventId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSendTurnInput, + type ProviderSession, + type ProviderUserInputAnswers, + type RuntimeContentStreamKind, + RuntimeItemId, + RuntimeRequestId, + ThreadId, + type ThreadTokenUsageSnapshot, + TurnId, + type UserInputQuestion, +} from "@okcode/contracts"; +import { CopilotClient, type CopilotSession, type SessionEvent } from "@github/copilot-sdk"; +import { + compactNodeProcessEnv, + mergeNodeProcessEnv, + sanitizeShellEnvironment, +} from "@okcode/shared/environment"; +import { Effect, Layer, Queue, Stream } from "effect"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { CopilotAdapter, type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; +import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = "copilot" as const; + +type CopilotPermissionRequest = + | { + readonly kind: "shell"; + readonly fullCommandText?: string; + } + | { + readonly kind: "write"; + readonly fileName?: string; + } + | { + readonly kind: "read"; + readonly path?: string; + } + | { + readonly kind: "mcp"; + readonly toolTitle?: string; + } + | { + readonly kind: "url"; + readonly url?: string; + } + | { + readonly kind: "custom-tool"; + readonly toolName?: string; + } + | { + readonly kind: string; + readonly fullCommandText?: string; + readonly fileName?: string; + readonly path?: string; + readonly toolTitle?: string; + readonly url?: string; + readonly toolName?: string; + }; + +type CopilotPermissionRequestResult = + | { readonly kind: "approved" } + | { readonly kind: "denied-interactively-by-user" }; + +interface CopilotUserInputRequest { + readonly question: string; + readonly choices?: ReadonlyArray; + readonly allowFreeform?: boolean; +} + +interface CopilotUserInputResponse { + readonly answer: string; + readonly wasFreeform: boolean; +} + +interface CopilotResumeCursor { + readonly version: 1; + readonly sessionId: string; +} + +interface PendingApproval { + readonly requestType: CanonicalRequestType; + readonly detail?: string; + readonly promise: Promise; + readonly resolve: (decision: ProviderApprovalDecision) => void; +} + +interface PendingUserInput { + readonly question: UserInputQuestion; + readonly promise: Promise; + readonly resolve: (answers: ProviderUserInputAnswers) => void; +} + +interface CopilotTurnState { + readonly turnId: TurnId; + readonly startedAt: string; + readonly items: Array; + readonly assistantItemIds: Set; + readonly toolItemIds: Set; +} + +interface CopilotSessionContext { + session: ProviderSession; + readonly client: CopilotClient; + readonly copilotSession: CopilotSession; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + turnState: CopilotTurnState | undefined; + lastUsage: ThreadTokenUsageSnapshot | undefined; + stopped: boolean; +} + +export interface CopilotAdapterLiveOptions { + readonly nativeEventLogger?: EventNdjsonLogger; +} + +function nowIsoString(): string { + return new Date().toISOString(); +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function makeResumeCursor(sessionId: string): CopilotResumeCursor { + return { version: 1, sessionId }; +} + +function readResumeCursor(cursor: unknown): CopilotResumeCursor | undefined { + if ( + typeof cursor === "object" && + cursor !== null && + "version" in cursor && + "sessionId" in cursor && + (cursor as Record).version === 1 && + typeof (cursor as Record).sessionId === "string" + ) { + return cursor as CopilotResumeCursor; + } + return undefined; +} + +function toRequestError(threadId: ThreadId, method: string, cause: unknown): ProviderAdapterError { + const message = toMessage(cause, `${method} failed`); + if (message.toLowerCase().includes("not found")) { + return new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + cause, + }); + } + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: message, + cause, + }); +} + +function toProcessError(threadId: ThreadId, detail: string, cause?: unknown): ProviderAdapterError { + return new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function mapInteractionModeToCopilotMode( + interactionMode: ProviderSendTurnInput["interactionMode"], +): "interactive" | "plan" | "autopilot" { + switch (interactionMode) { + case "plan": + return "plan"; + case "code": + return "autopilot"; + case "chat": + default: + return "interactive"; + } +} + +function mapApprovalDecisionToPermissionResult( + decision: ProviderApprovalDecision, +): CopilotPermissionRequestResult { + switch (decision) { + case "accept": + case "acceptForSession": + return { kind: "approved" }; + case "decline": + case "cancel": + default: + return { kind: "denied-interactively-by-user" }; + } +} + +function inferRequestType(request: CopilotPermissionRequest): CanonicalRequestType { + switch (request.kind) { + case "shell": + return "exec_command_approval"; + case "write": + return "apply_patch_approval"; + case "read": + return "file_read_approval"; + case "mcp": + case "custom-tool": + return "dynamic_tool_call"; + case "url": + return "unknown"; + default: + return "unknown"; + } +} + +function permissionDetail(request: CopilotPermissionRequest): string | undefined { + if (request.kind === "shell" && typeof request.fullCommandText === "string") { + return request.fullCommandText; + } + if (request.kind === "write" && typeof request.fileName === "string") { + return request.fileName; + } + if (request.kind === "read" && typeof request.path === "string") { + return request.path; + } + if (request.kind === "mcp" && typeof request.toolTitle === "string") { + return request.toolTitle; + } + if (request.kind === "url" && typeof request.url === "string") { + return request.url; + } + if (request.kind === "custom-tool" && typeof request.toolName === "string") { + return request.toolName; + } + return undefined; +} + +function inferToolItemType(toolName: string): CanonicalItemType { + const normalized = toolName.trim().toLowerCase(); + if (normalized === "shell" || normalized.includes("bash")) return "command_execution"; + if (normalized.includes("write") || normalized.includes("edit") || normalized.includes("patch")) + return "file_change"; + if (normalized.includes("read")) return "dynamic_tool_call"; + if (normalized.includes("mcp")) return "mcp_tool_call"; + if (normalized.includes("web") || normalized.includes("fetch") || normalized.includes("search")) + return "web_search"; + if (normalized.includes("image")) return "image_view"; + return "dynamic_tool_call"; +} + +function inferToolStreamKind(itemType: CanonicalItemType): RuntimeContentStreamKind { + switch (itemType) { + case "command_execution": + return "command_output"; + case "file_change": + return "file_change_output"; + default: + return "unknown"; + } +} + +function normalizeUsageFromAssistantEvent( + event: Extract, +) { + const usedTokens = (event.data.inputTokens ?? 0) + (event.data.outputTokens ?? 0); + if (usedTokens <= 0) { + return undefined; + } + return { + usedTokens, + ...(event.data.inputTokens !== undefined ? { inputTokens: event.data.inputTokens } : {}), + ...(event.data.outputTokens !== undefined ? { outputTokens: event.data.outputTokens } : {}), + ...(event.data.cacheReadTokens !== undefined + ? { cachedInputTokens: event.data.cacheReadTokens } + : {}), + ...(event.data.reasoningEffort ? { compactsAutomatically: true } : {}), + ...(event.data.duration !== undefined ? { durationMs: event.data.duration } : {}), + lastUsedTokens: usedTokens, + ...(event.data.inputTokens !== undefined ? { lastInputTokens: event.data.inputTokens } : {}), + ...(event.data.outputTokens !== undefined ? { lastOutputTokens: event.data.outputTokens } : {}), + } satisfies ThreadTokenUsageSnapshot; +} + +function buildUserInputQuestion( + requestId: string, + request: CopilotUserInputRequest, +): UserInputQuestion { + return { + id: requestId, + header: "Copilot", + question: request.question.trim() || "GitHub Copilot needs input.", + options: (request.choices ?? []).map((choice: string) => ({ + label: choice, + description: choice, + })), + }; +} + +function readCopilotProviderOptions(input: { readonly providerOptions?: unknown }) { + if (!input.providerOptions || typeof input.providerOptions !== "object") { + return {}; + } + const providerOptions = input.providerOptions as Record; + const copilot = providerOptions.copilot; + if (!copilot || typeof copilot !== "object") { + return {}; + } + const record = copilot as Record; + return { + ...(typeof record.binaryPath === "string" ? { binaryPath: record.binaryPath } : {}), + ...(typeof record.configDir === "string" ? { configDir: record.configDir } : {}), + }; +} + +function getCopilotReasoningEffort(input: ProviderSendTurnInput): string | undefined { + return input.modelOptions?.copilot?.reasoningEffort; +} + +export function makeCopilotAdapterLive(options?: CopilotAdapterLiveOptions) { + return Layer.effect(CopilotAdapter, makeCopilotAdapter(options)); +} + +const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => + Effect.gen(function* () { + const runtimeEventQueue = yield* Queue.unbounded(); + const sessions = new Map(); + + yield* Effect.addFinalizer(() => + Effect.forEach( + Array.from(sessions.keys()), + (threadId) => stopSession(threadId).pipe(Effect.ignore), + { discard: true }, + ).pipe(Effect.ensuring(Queue.shutdown(runtimeEventQueue))), + ); + const emitEvent = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEventQueue, event).pipe( + Effect.tap(() => + options?.nativeEventLogger ? options.nativeEventLogger.write(event, null) : Effect.void, + ), + Effect.asVoid, + ); + + const makeBase = ( + threadId: ThreadId, + extra?: { + readonly turnId?: TurnId; + readonly itemId?: string; + readonly requestId?: string; + }, + raw?: SessionEvent, + ) => ({ + eventId: EventId.makeUnsafe(crypto.randomUUID()), + provider: PROVIDER, + threadId, + createdAt: nowIsoString(), + ...(extra?.turnId ? { turnId: extra.turnId } : {}), + ...(extra?.itemId ? { itemId: RuntimeItemId.makeUnsafe(extra.itemId) } : {}), + ...(extra?.requestId ? { requestId: RuntimeRequestId.makeUnsafe(extra.requestId) } : {}), + providerRefs: {}, + ...(raw + ? { + raw: { + source: "copilot.sdk.event" as const, + messageType: raw.type, + payload: raw, + }, + } + : {}), + }); + + const getContext = (threadId: ThreadId) => { + const context = sessions.get(threadId); + if (!context) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }), + ); + } + if (context.stopped) { + return Effect.fail( + new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + }), + ); + } + return Effect.succeed(context); + }; + + const ensureAssistantItem = ( + context: CopilotSessionContext, + messageId: string, + raw: SessionEvent, + ) => + Effect.gen(function* () { + const turnState = context.turnState; + if (!turnState) return; + const itemId = `assistant:${messageId}`; + if (turnState.assistantItemIds.has(itemId)) return; + turnState.assistantItemIds.add(itemId); + turnState.items.push({ itemId, itemType: "assistant_message" }); + yield* emitEvent({ + ...makeBase(context.session.threadId, { turnId: turnState.turnId, itemId }, raw), + type: "item.started", + payload: { + itemType: "assistant_message", + title: "Assistant message", + }, + }); + }); + + const ensureToolItem = ( + context: CopilotSessionContext, + toolCallId: string, + toolName: string, + raw: SessionEvent, + ) => + Effect.gen(function* () { + const turnState = context.turnState; + if (!turnState) + return { itemId: `tool:${toolCallId}`, itemType: inferToolItemType(toolName) }; + const itemId = `tool:${toolCallId}`; + const itemType = inferToolItemType(toolName); + if (!turnState.toolItemIds.has(itemId)) { + turnState.toolItemIds.add(itemId); + turnState.items.push({ itemId, itemType, toolName }); + yield* emitEvent({ + ...makeBase(context.session.threadId, { turnId: turnState.turnId, itemId }, raw), + type: "item.started", + payload: { + itemType, + title: toolName, + data: { + toolName, + }, + }, + }); + } + return { itemId, itemType }; + }); + + const completeTurn = ( + context: CopilotSessionContext, + state: "completed" | "failed" | "cancelled" | "interrupted", + raw?: SessionEvent, + errorMessage?: string, + ) => + Effect.gen(function* () { + const turnState = context.turnState; + if (!turnState) return; + context.turns.push({ id: turnState.turnId, items: [...turnState.items] }); + context.turnState = undefined; + context.session = { + ...context.session, + status: state === "failed" ? "error" : "ready", + activeTurnId: undefined, + updatedAt: nowIsoString(), + ...(errorMessage ? { lastError: errorMessage } : {}), + }; + yield* emitEvent({ + ...makeBase(context.session.threadId, { turnId: turnState.turnId }, raw), + type: "turn.completed", + payload: { + state, + ...(context.lastUsage ? { usage: context.lastUsage } : {}), + ...(errorMessage ? { errorMessage } : {}), + }, + }); + yield* emitEvent({ + ...makeBase(context.session.threadId, { turnId: turnState.turnId }, raw), + type: "session.state.changed", + payload: { + state: state === "failed" ? "error" : "ready", + ...(errorMessage ? { reason: errorMessage } : {}), + }, + }); + }); + + const handleSessionEvent = (context: CopilotSessionContext, event: SessionEvent) => + Effect.gen(function* () { + switch (event.type) { + case "assistant.message_delta": { + const turnState = context.turnState; + if (!turnState) return; + yield* ensureAssistantItem(context, event.data.messageId, event); + yield* emitEvent({ + ...makeBase( + context.session.threadId, + { turnId: turnState.turnId, itemId: `assistant:${event.data.messageId}` }, + event, + ), + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: event.data.deltaContent, + }, + }); + return; + } + case "assistant.message": { + const turnState = context.turnState; + if (!turnState) return; + const itemId = `assistant:${event.data.messageId}`; + yield* ensureAssistantItem(context, event.data.messageId, event); + if (event.data.reasoningText?.trim()) { + const reasoningItemId = `${itemId}:reasoning`; + yield* emitEvent({ + ...makeBase( + context.session.threadId, + { turnId: turnState.turnId, itemId: reasoningItemId }, + event, + ), + type: "item.started", + payload: { + itemType: "reasoning", + title: "Reasoning", + }, + }); + yield* emitEvent({ + ...makeBase( + context.session.threadId, + { turnId: turnState.turnId, itemId: reasoningItemId }, + event, + ), + type: "content.delta", + payload: { + streamKind: "reasoning_text", + delta: event.data.reasoningText, + }, + }); + yield* emitEvent({ + ...makeBase( + context.session.threadId, + { turnId: turnState.turnId, itemId: reasoningItemId }, + event, + ), + type: "item.completed", + payload: { + itemType: "reasoning", + status: "completed", + title: "Reasoning", + data: { + text: event.data.reasoningText, + }, + }, + }); + } + yield* emitEvent({ + ...makeBase(context.session.threadId, { turnId: turnState.turnId, itemId }, event), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + data: { + text: event.data.content, + ...(event.data.toolRequests ? { toolRequests: event.data.toolRequests } : {}), + }, + }, + }); + return; + } + case "assistant.usage": { + context.lastUsage = normalizeUsageFromAssistantEvent(event); + if (!context.lastUsage) return; + yield* emitEvent({ + ...makeBase( + context.session.threadId, + context.turnState ? { turnId: context.turnState.turnId } : undefined, + event, + ), + type: "thread.token-usage.updated", + payload: { + usage: context.lastUsage, + }, + }); + return; + } + case "tool.execution_start": { + const { itemId, itemType } = yield* ensureToolItem( + context, + event.data.toolCallId, + event.data.toolName, + event, + ); + yield* emitEvent({ + ...makeBase( + context.session.threadId, + context.turnState ? { turnId: context.turnState.turnId, itemId } : { itemId }, + event, + ), + type: "item.updated", + payload: { + itemType, + status: "inProgress", + title: event.data.toolName, + data: { + ...(event.data.arguments ? { arguments: event.data.arguments } : {}), + ...(event.data.mcpServerName ? { mcpServerName: event.data.mcpServerName } : {}), + }, + }, + }); + return; + } + case "tool.execution_partial_result": { + const turnState = context.turnState; + if (!turnState) return; + const toolItem = turnState.items.find( + (item) => + typeof item === "object" && + item !== null && + "itemId" in item && + (item as { itemId?: string }).itemId === `tool:${event.data.toolCallId}`, + ) as { itemId?: string; itemType?: CanonicalItemType } | undefined; + const itemId = toolItem?.itemId ?? `tool:${event.data.toolCallId}`; + const itemType = toolItem?.itemType ?? "dynamic_tool_call"; + yield* emitEvent({ + ...makeBase(context.session.threadId, { turnId: turnState.turnId, itemId }, event), + type: "content.delta", + payload: { + streamKind: inferToolStreamKind(itemType), + delta: event.data.partialOutput, + }, + }); + return; + } + case "tool.execution_complete": { + const turnState = context.turnState; + const toolItem = turnState?.items.find( + (item) => + typeof item === "object" && + item !== null && + "itemId" in item && + (item as { itemId?: string }).itemId === `tool:${event.data.toolCallId}`, + ) as { itemId?: string; itemType?: CanonicalItemType; toolName?: string } | undefined; + const itemId = toolItem?.itemId ?? `tool:${event.data.toolCallId}`; + const itemType = toolItem?.itemType ?? "dynamic_tool_call"; + yield* emitEvent({ + ...makeBase( + context.session.threadId, + turnState ? { turnId: turnState.turnId, itemId } : { itemId }, + event, + ), + type: "item.completed", + payload: { + itemType, + status: event.data.success ? "completed" : "failed", + title: toolItem?.toolName ?? "Tool execution", + data: { + success: event.data.success, + ...(event.data.result !== undefined ? { result: event.data.result } : {}), + ...(event.data.error ? { error: event.data.error } : {}), + }, + }, + }); + return; + } + case "session.idle": + yield* completeTurn(context, "completed", event); + return; + case "abort": + yield* completeTurn(context, "interrupted", event, event.data.reason); + return; + case "session.warning": + yield* emitEvent({ + ...makeBase( + context.session.threadId, + context.turnState ? { turnId: context.turnState.turnId } : undefined, + event, + ), + type: "runtime.warning", + payload: { + message: event.data.message, + detail: event.data, + }, + }); + return; + case "session.error": + yield* emitEvent({ + ...makeBase( + context.session.threadId, + context.turnState ? { turnId: context.turnState.turnId } : undefined, + event, + ), + type: "runtime.error", + payload: { + message: event.data.message, + class: "provider_error", + detail: event.data, + }, + }); + yield* completeTurn(context, "failed", event, event.data.message); + return; + default: + return; + } + }); + + const startSession: CopilotAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + const now = nowIsoString(); + const threadId = input.threadId; + const resolvedCwd = input.cwd ?? process.cwd(); + const providerOptions = readCopilotProviderOptions(input); + const sessionEnv = sanitizeShellEnvironment( + mergeNodeProcessEnv( + process.env, + input.env ? compactNodeProcessEnv(input.env) : undefined, + ), + ); + + const client = new CopilotClient({ + ...(providerOptions.binaryPath ? { cliPath: providerOptions.binaryPath } : {}), + cwd: resolvedCwd, + env: sessionEnv, + logLevel: "error", + }); + + yield* Effect.tryPromise({ + try: () => client.start(), + catch: (cause) => + toProcessError(threadId, "Failed to start GitHub Copilot CLI client.", cause), + }); + + const pendingApprovals = new Map(); + let context: CopilotSessionContext | undefined; + const onPermissionRequest = (request: CopilotPermissionRequest) => { + if (input.runtimeMode === "full-access") { + return { kind: "approved" } satisfies CopilotPermissionRequestResult; + } + const requestId = ApprovalRequestId.makeUnsafe( + `copilot-permission:${crypto.randomUUID()}`, + ); + let resolve!: (decision: ProviderApprovalDecision) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + const detail = permissionDetail(request); + pendingApprovals.set(requestId, { + requestType: inferRequestType(request), + ...(detail ? { detail } : {}), + promise, + resolve, + }); + void Effect.runPromise( + emitEvent({ + ...makeBase( + threadId, + context?.turnState + ? { turnId: context.turnState.turnId, requestId } + : { requestId }, + ), + type: "request.opened", + payload: { + requestType: inferRequestType(request), + ...(detail ? { detail } : {}), + args: request, + }, + }), + ); + return promise.then((decision) => { + const result = mapApprovalDecisionToPermissionResult(decision); + void Effect.runPromise( + emitEvent({ + ...makeBase( + threadId, + context?.turnState + ? { turnId: context.turnState.turnId, requestId } + : { requestId }, + ), + type: "request.resolved", + payload: { + requestType: inferRequestType(request), + decision: result.kind, + resolution: result, + }, + }).pipe(Effect.ensuring(Effect.sync(() => pendingApprovals.delete(requestId)))), + ); + return result; + }); + }; + + const reasonEffort = input.modelOptions?.copilot?.reasoningEffort; + const sessionConfig = { + ...(input.model ? { model: input.model } : {}), + ...(reasonEffort ? { reasoningEffort: reasonEffort } : {}), + workingDirectory: resolvedCwd, + streaming: true, + onPermissionRequest, + ...(providerOptions.configDir ? { configDir: providerOptions.configDir } : {}), + }; + + const resumeCursor = readResumeCursor(input.resumeCursor); + const copilotSession = yield* Effect.tryPromise({ + try: () => + resumeCursor + ? client.resumeSession(resumeCursor.sessionId, { + ...sessionConfig, + }) + : client.createSession({ + ...sessionConfig, + }), + catch: (cause) => + toProcessError(threadId, "Failed to create GitHub Copilot session.", cause), + }).pipe( + Effect.tapError(() => + Effect.promise(() => + client + .stop() + .then(() => undefined) + .catch(() => undefined), + ), + ), + ); + + const session: ProviderSession = { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + cwd: resolvedCwd, + model: input.model, + threadId, + createdAt: now, + updatedAt: now, + resumeCursor: makeResumeCursor(copilotSession.sessionId), + }; + + context = { + session, + client, + copilotSession, + pendingApprovals, + pendingUserInputs: new Map(), + turns: [], + turnState: undefined, + lastUsage: undefined, + stopped: false, + }; + + copilotSession.on((event) => { + void Effect.runPromise(handleSessionEvent(context, event)); + }); + + copilotSession.registerUserInputHandler((request) => { + const requestId = ApprovalRequestId.makeUnsafe( + `copilot-user-input:${crypto.randomUUID()}`, + ); + let resolve!: (answers: ProviderUserInputAnswers) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + const question = buildUserInputQuestion(requestId, request); + context.pendingUserInputs.set(requestId, { + question, + promise, + resolve, + }); + void Effect.runPromise( + emitEvent({ + ...makeBase( + threadId, + context.turnState ? { turnId: context.turnState.turnId, requestId } : { requestId }, + ), + type: "user-input.requested", + payload: { + questions: [question], + }, + }), + ); + return promise.then((answers) => { + const answerValue = answers[requestId] ?? answers[question.id]; + const answer = typeof answerValue === "string" ? answerValue : ""; + void Effect.runPromise( + emitEvent({ + ...makeBase( + threadId, + context.turnState + ? { turnId: context.turnState.turnId, requestId } + : { requestId }, + ), + type: "user-input.resolved", + payload: { + answers: { + [question.id]: answer, + }, + }, + }).pipe( + Effect.ensuring(Effect.sync(() => context.pendingUserInputs.delete(requestId))), + ), + ); + return { + answer, + wasFreeform: !(request.choices ?? []).includes(answer), + } satisfies CopilotUserInputResponse; + }); + }); + + sessions.set(threadId, context); + + yield* emitEvent({ + ...makeBase(threadId), + type: "session.started", + payload: { + message: "GitHub Copilot session started.", + resume: makeResumeCursor(copilotSession.sessionId), + }, + }); + yield* emitEvent({ + ...makeBase(threadId), + type: "thread.started", + payload: { + providerThreadId: copilotSession.sessionId, + }, + }); + yield* emitEvent({ + ...makeBase(threadId), + type: "session.state.changed", + payload: { + state: "ready", + }, + }); + + return context.session; + }); + + const sendTurn: CopilotAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const context = yield* getContext(input.threadId); + if (context.turnState) { + return yield* Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.send", + detail: "GitHub Copilot already has an active turn for this thread.", + }), + ); + } + + const turnId = TurnId.makeUnsafe(crypto.randomUUID()); + context.turnState = { + turnId, + startedAt: nowIsoString(), + items: [], + assistantItemIds: new Set(), + toolItemIds: new Set(), + }; + context.lastUsage = undefined; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt: nowIsoString(), + ...(input.model ? { model: input.model } : {}), + }; + + const reasoningEffort = getCopilotReasoningEffort(input); + const nextModel = input.model; + if (nextModel) { + yield* Effect.tryPromise({ + try: () => + context.copilotSession.rpc.model.switchTo({ + modelId: nextModel, + ...(reasoningEffort ? { reasoningEffort } : {}), + }), + catch: (cause) => toRequestError(input.threadId, "session.model.switchTo", cause), + }); + } + + yield* Effect.tryPromise({ + try: () => + context.copilotSession.rpc.mode.set({ + mode: mapInteractionModeToCopilotMode(input.interactionMode), + }), + catch: (cause) => toRequestError(input.threadId, "session.mode.set", cause), + }); + + yield* emitEvent({ + ...makeBase(input.threadId, { turnId }), + type: "session.state.changed", + payload: { + state: "running", + }, + }); + yield* emitEvent({ + ...makeBase(input.threadId, { turnId }), + type: "turn.started", + payload: { + ...(context.session.model ? { model: context.session.model } : {}), + ...(reasoningEffort ? { effort: reasoningEffort } : {}), + }, + }); + + yield* Effect.tryPromise({ + try: () => + context.copilotSession.send({ + prompt: input.input ?? "", + }), + catch: (cause) => toRequestError(input.threadId, "session.send", cause), + }); + + return { + threadId: input.threadId, + turnId, + resumeCursor: makeResumeCursor(context.copilotSession.sessionId), + }; + }); + + const interruptTurn: CopilotAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const context = yield* getContext(threadId); + yield* Effect.tryPromise({ + try: () => context.copilotSession.abort(), + catch: (cause) => toRequestError(threadId, "session.abort", cause), + }); + }); + + const respondToRequest: CopilotAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const context = yield* getContext(threadId); + const pending = context.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.permissions.handlePendingPermissionRequest", + detail: `Unknown pending approval request '${requestId}'.`, + }); + } + yield* Effect.sync(() => pending.resolve(decision)); + }); + + const respondToUserInput: CopilotAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const context = yield* getContext(threadId); + const pending = context.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.userInput.respond", + detail: `Unknown pending user input request '${requestId}'.`, + }); + } + yield* Effect.sync(() => pending.resolve(answers)); + }); + + const stopContext = (context: CopilotSessionContext) => + Effect.gen(function* () { + if (context.stopped) return; + context.stopped = true; + for (const pending of context.pendingApprovals.values()) { + pending.resolve("cancel"); + } + for (const pending of context.pendingUserInputs.values()) { + pending.resolve({}); + } + yield* Effect.promise(() => context.copilotSession.disconnect().catch(() => undefined)); + yield* Effect.promise(() => + context.client + .stop() + .then(() => undefined) + .catch(() => undefined), + ); + yield* emitEvent({ + ...makeBase(context.session.threadId), + type: "session.exited", + payload: { + reason: "Session stopped.", + exitKind: "graceful", + }, + }); + }); + + const stopSession: CopilotAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const context = yield* getContext(threadId); + yield* stopContext(context); + sessions.delete(threadId); + }); + + const listSessions: CopilotAdapterShape["listSessions"] = () => + Effect.sync(() => + [...sessions.values()] + .filter((context) => !context.stopped) + .map((context) => context.session), + ); + + const hasSession: CopilotAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const context = sessions.get(threadId); + return context !== undefined && !context.stopped; + }); + + const readThread: CopilotAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const context = yield* getContext(threadId); + const turns = [...context.turns]; + if (context.turnState) { + turns.push({ id: context.turnState.turnId, items: [...context.turnState.items] }); + } + return { + threadId, + turns, + }; + }); + + const rollbackThread: CopilotAdapterShape["rollbackThread"] = (threadId) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.rollback", + detail: `GitHub Copilot rollback is not implemented for thread '${threadId}'.`, + }), + ); + + const stopAll: CopilotAdapterShape["stopAll"] = () => + Effect.promise(async () => { + await Promise.all( + [...sessions.values()].map((context) => + Effect.runPromise(stopContext(context).pipe(Effect.asVoid)), + ), + ); + }); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents: Stream.fromQueue(runtimeEventQueue), + } satisfies CopilotAdapterShape; + }); + +export const CopilotAdapterLive = makeCopilotAdapterLive(); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 2702b336..9d8939d1 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -5,6 +5,7 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; import { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { CopilotAdapter, CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { OpenClawAdapter, OpenClawAdapterShape } from "../Services/OpenClawAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; @@ -63,6 +64,23 @@ const fakeOpenClawAdapter: OpenClawAdapterShape = { streamEvents: Stream.empty, }; +const fakeCopilotAdapter: CopilotAdapterShape = { + provider: "copilot", + capabilities: { sessionModelSwitch: "in-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( Layer.provide( @@ -71,6 +89,7 @@ const layer = it.layer( Layer.succeed(CodexAdapter, fakeCodexAdapter), Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), Layer.succeed(OpenClawAdapter, fakeOpenClawAdapter), + Layer.succeed(CopilotAdapter, fakeCopilotAdapter), ), ), NodeServices.layer, @@ -87,7 +106,7 @@ layer("ProviderAdapterRegistryLive", (it) => { assert.equal(claude, fakeClaudeAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeAgent", "openclaw"]); + assert.deepEqual(providers, ["codex", "claudeAgent", "openclaw", "copilot"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index cb4dd06d..257219a3 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -16,6 +16,7 @@ import { type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; +import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { OpenClawAdapter } from "../Services/OpenClawAdapter.ts"; @@ -28,7 +29,12 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter, yield* ClaudeAdapter, yield* OpenClawAdapter]; + : [ + yield* CodexAdapter, + yield* ClaudeAdapter, + yield* OpenClawAdapter, + yield* CopilotAdapter, + ]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 0a13aeee..927b5181 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -8,6 +8,7 @@ * @module ProviderHealthLive */ import * as OS from "node:os"; +import { CopilotClient } from "@github/copilot-sdk"; import type { ServerProviderAuthStatus, ServerProviderStatus, @@ -29,11 +30,20 @@ import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHe const DEFAULT_TIMEOUT_MS = 4_000; const CODEX_PROVIDER = "codex" as const; const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const; +const COPILOT_PROVIDER = "copilot" as const; class OpenClawHealthProbeError extends Data.TaggedError("OpenClawHealthProbeError")<{ cause: unknown; }> {} +class CopilotHealthProbeError extends Data.TaggedError("CopilotHealthProbeError")<{ + cause: unknown; +}> {} + +function formatHealthProbeCause(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + const OPENCLAW_HEALTH_REQUIRED_METHODS = [ "sessions.create", "sessions.get", @@ -323,6 +333,101 @@ const runClaudeCommand = (args: ReadonlyArray) => return { stdout, stderr, code: exitCode } satisfies CommandResult; }).pipe(Effect.scoped); +export const checkCopilotProviderStatus: Effect.Effect = + Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + const client = new CopilotClient({ logLevel: "error" }); + + const started = yield* Effect.tryPromise({ + try: () => client.start(), + catch: (cause) => new CopilotHealthProbeError({ cause }), + }).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(started)) { + const error = started.failure; + return { + provider: COPILOT_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: + error instanceof CopilotHealthProbeError + ? `Failed to start GitHub Copilot CLI: ${formatHealthProbeCause(error.cause)}.` + : "Failed to start GitHub Copilot CLI.", + } satisfies ServerProviderStatus; + } + + if (Option.isNone(started.success)) { + yield* Effect.promise(() => + client + .forceStop() + .then(() => undefined) + .catch(() => undefined), + ); + return { + provider: COPILOT_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: "GitHub Copilot CLI timed out while starting.", + } satisfies ServerProviderStatus; + } + + const authResult = yield* Effect.tryPromise({ + try: () => client.getAuthStatus(), + catch: (cause) => new CopilotHealthProbeError({ cause }), + }).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); + yield* Effect.promise(() => + client + .stop() + .then(() => undefined) + .catch(() => undefined), + ); + + if (Result.isFailure(authResult)) { + const error = authResult.failure; + return { + provider: COPILOT_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: + error instanceof CopilotHealthProbeError + ? `Could not verify GitHub Copilot authentication status: ${formatHealthProbeCause(error.cause)}.` + : "Could not verify GitHub Copilot authentication status.", + } satisfies ServerProviderStatus; + } + + if (Option.isNone(authResult.success)) { + return { + provider: COPILOT_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: "Could not verify GitHub Copilot authentication status. Timed out while checking.", + } satisfies ServerProviderStatus; + } + + const authStatus = authResult.success.value as { + readonly isAuthenticated: boolean; + readonly statusMessage?: string; + }; + return { + provider: COPILOT_PROVIDER, + status: authStatus.isAuthenticated ? ("ready" as const) : ("error" as const), + available: true, + authStatus: authStatus.isAuthenticated + ? ("authenticated" as const) + : ("unauthenticated" as const), + checkedAt, + ...(authStatus.statusMessage ? { message: authStatus.statusMessage } : {}), + } satisfies ServerProviderStatus; + }); + // ── Health check ──────────────────────────────────────────────────── export const checkCodexProviderStatus: Effect.Effect< @@ -822,7 +927,12 @@ export const ProviderHealthLive = Layer.effect( return { getStatuses: Effect.all( - [checkCodexProviderStatus, checkClaudeProviderStatus, checkOpenClawProviderStatus], + [ + checkCodexProviderStatus, + checkClaudeProviderStatus, + checkCopilotProviderStatus, + checkOpenClawProviderStatus, + ], { concurrency: "unbounded", }, diff --git a/apps/server/src/provider/Services/CopilotAdapter.ts b/apps/server/src/provider/Services/CopilotAdapter.ts new file mode 100644 index 00000000..2677e4ca --- /dev/null +++ b/apps/server/src/provider/Services/CopilotAdapter.ts @@ -0,0 +1,12 @@ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface CopilotAdapterShape extends ProviderAdapterShape { + readonly provider: "copilot"; +} + +export class CopilotAdapter extends ServiceMap.Service()( + "okcode/provider/Services/CopilotAdapter", +) {} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 1f6ec9a8..7750a2ce 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -20,6 +20,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; import { ProviderUnsupportedError } from "./provider/Errors"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; +import { makeCopilotAdapterLive } from "./provider/Layers/CopilotAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { makeOpenClawAdapterLive } from "./provider/Layers/OpenClawAdapter"; import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; @@ -103,10 +104,14 @@ export function makeServerProviderLayer(): Layer.Layer< const openclawAdapterLayer = makeOpenClawAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ).pipe(Layer.provideMerge(OpenclawGatewayConfigLive)); + const copilotAdapterLayer = makeCopilotAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), Layer.provide(openclawAdapterLayer), + Layer.provide(copilotAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.ts index ac59229b..d7058e3d 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.ts @@ -175,6 +175,14 @@ const makeSmeChatService = () => new SmeChatError("validateSetup", "Failed to validate Codex setup.", cause), }); + case "copilot": + return { + ok: false, + severity: "warning" as const, + message: "GitHub Copilot is not available in SME Chat yet.", + resolvedAuthMethod: "auto" as const, + }; + case "openclaw": const openclawSummary = yield* openclawGatewayConfig .getSummary() diff --git a/apps/server/src/sme/authValidation.ts b/apps/server/src/sme/authValidation.ts index 2c348804..06a8b014 100644 --- a/apps/server/src/sme/authValidation.ts +++ b/apps/server/src/sme/authValidation.ts @@ -22,6 +22,8 @@ export function getAllowedSmeAuthMethods(provider: ProviderKind): readonly SmeAu switch (provider) { case "claudeAgent": return ["auto", "apiKey", "authToken"]; + case "copilot": + return ["auto"]; case "codex": return ["auto", "chatgpt", "apiKey", "customProvider"]; case "openclaw": @@ -32,6 +34,8 @@ export function getAllowedSmeAuthMethods(provider: ProviderKind): readonly SmeAu export function getDefaultSmeAuthMethod(provider: ProviderKind): SmeAuthMethod { switch (provider) { case "claudeAgent": + return "apiKey"; + case "copilot": return "auto"; case "codex": return "chatgpt"; diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 39c4c3de..6e7d53eb 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -50,7 +50,11 @@ export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "update export const PrReviewRequestChangesTone = Schema.Literals(["warning", "brand", "neutral"]); export type PrReviewRequestChangesTone = typeof PrReviewRequestChangesTone.Type; export const DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE: PrReviewRequestChangesTone = "warning"; -type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels" | "customOpenClawModels"; +type CustomModelSettingsKey = + | "customCodexModels" + | "customClaudeModels" + | "customOpenClawModels" + | "customCopilotModels"; export type ProviderCustomModelConfig = { provider: ProviderKind; settingsKey: CustomModelSettingsKey; @@ -65,6 +69,7 @@ const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record codex: new Set(getModelOptions("codex").map((option) => option.slug)), claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)), openclaw: new Set(getModelOptions("openclaw").map((option) => option.slug)), + copilot: new Set(getModelOptions("copilot").map((option) => option.slug)), }; const withDefaults = @@ -82,6 +87,8 @@ const withDefaults = export const AppSettingsSchema = Schema.Struct({ claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + copilotBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + copilotConfigDir: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), claudeAuthTokenHelperCommand: Schema.String.check(Schema.isMaxLength(4096)).pipe( withDefaults(() => ""), ), @@ -130,6 +137,7 @@ export const AppSettingsSchema = Schema.Struct({ codeViewerAutosave: Schema.Boolean.pipe(withDefaults(() => false)), customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), + customCopilotModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customOpenClawModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), openclawGatewayUrl: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), openclawPassword: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), @@ -162,6 +170,15 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record 0 ? providerOptions : undefined; diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index c61baa0b..7b1dfcd5 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -15,6 +15,7 @@ const MODEL_OPTIONS_BY_PROVIDER = { { slug: "gpt-5-codex", name: "GPT-5 Codex" }, { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, ], + copilot: [{ slug: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }], openclaw: [], } as const satisfies Record>; diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 0b4e6dfc..0f030a8f 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -15,7 +15,16 @@ import { MenuSeparator as MenuDivider, MenuTrigger, } from "../ui/menu"; -import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenClawIcon, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + Gemini, + GitHubIcon, + Icon, + OpenAI, + OpenClawIcon, + OpenCodeIcon, +} from "../Icons"; import { cn } from "~/lib/utils"; import { getThreadProviderLabel } from "~/lib/providerAvailability"; @@ -23,6 +32,7 @@ const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, claudeAgent: ClaudeAI, openclaw: OpenClawIcon, + copilot: GitHubIcon, cursor: CursorIcon, }; @@ -38,6 +48,7 @@ function providerIconClassName( ): string { if (provider === "claudeAgent") return "text-[#d97757]"; if (provider === "openclaw") return "text-[#6cb4ee]"; + if (provider === "copilot") return "text-white/85"; return fallbackClassName; } diff --git a/apps/web/src/components/chat/ProviderSetupCard.tsx b/apps/web/src/components/chat/ProviderSetupCard.tsx index eaa900be..20eb2b58 100644 --- a/apps/web/src/components/chat/ProviderSetupCard.tsx +++ b/apps/web/src/components/chat/ProviderSetupCard.tsx @@ -31,6 +31,11 @@ const PROVIDER_CONFIG = { verifyCmd: "claude auth status", note: "You can also configure a Claude auth token helper command or one-click secret-manager preset in Settings.", }, + copilot: { + installCmd: "npm install -g @github/copilot", + authCmd: "copilot login", + verifyCmd: "gh auth status", + }, } as const; function StatusIcon({ status }: { status: ServerProviderStatus["status"] }) { diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index aebbefd1..4c5472b5 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -120,6 +120,25 @@ const composerProviderRegistry: Record = { renderTraitsMenuContent: () => null, renderTraitsPicker: () => null, }, + copilot: { + getState: ({ modelOptions }) => { + const defaultPromptEffort = getDefaultReasoningEffort("copilot"); + const promptEffort = + resolveReasoningEffortForProvider("copilot", modelOptions?.copilot?.reasoningEffort) ?? + defaultPromptEffort; + + return { + provider: "copilot", + promptEffort, + modelOptionsForDispatch: + promptEffort !== defaultPromptEffort + ? { copilot: { reasoningEffort: promptEffort } } + : undefined, + }; + }, + renderTraitsMenuContent: () => null, + renderTraitsPicker: () => null, + }, }; export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { diff --git a/apps/web/src/components/chat/providerStatusPresentation.ts b/apps/web/src/components/chat/providerStatusPresentation.ts index 1d743e02..0b03ea75 100644 --- a/apps/web/src/components/chat/providerStatusPresentation.ts +++ b/apps/web/src/components/chat/providerStatusPresentation.ts @@ -7,6 +7,7 @@ const PROVIDER_LABELS = { codex: "OpenAI (Codex CLI)", claudeAgent: "Claude Code", openclaw: "OpenClaw", + copilot: "GitHub Copilot", } as const; export function getProviderLabel(provider: ServerProviderStatus["provider"]): string { diff --git a/apps/web/src/components/home/home-utils.ts b/apps/web/src/components/home/home-utils.ts index 229fca53..ceff5f41 100644 --- a/apps/web/src/components/home/home-utils.ts +++ b/apps/web/src/components/home/home-utils.ts @@ -12,6 +12,8 @@ export function getProviderLabel(provider: ServerProviderStatus["provider"]) { return "Codex"; case "openclaw": return "OpenClaw"; + case "copilot": + return "GitHub Copilot"; } } diff --git a/apps/web/src/components/sme/SmeConversationDialog.tsx b/apps/web/src/components/sme/SmeConversationDialog.tsx index 9b89f4f9..b88c7a3f 100644 --- a/apps/web/src/components/sme/SmeConversationDialog.tsx +++ b/apps/web/src/components/sme/SmeConversationDialog.tsx @@ -94,6 +94,7 @@ export function SmeConversationDialog({ { codex: settings.customCodexModels, claudeAgent: settings.customClaudeModels, + copilot: settings.customCopilotModels, openclaw: settings.customOpenClawModels, }, null, @@ -109,6 +110,7 @@ export function SmeConversationDialog({ conversation, open, settings.customClaudeModels, + settings.customCopilotModels, settings.customCodexModels, settings.customOpenClawModels, ]); @@ -147,6 +149,7 @@ export function SmeConversationDialog({ { codex: settings.customCodexModels, claudeAgent: settings.customClaudeModels, + copilot: settings.customCopilotModels, openclaw: settings.customOpenClawModels, }, nextProvider === "openclaw" ? "default" : null, diff --git a/apps/web/src/components/sme/smeConversationConfig.ts b/apps/web/src/components/sme/smeConversationConfig.ts index 8912d6c4..e7f72d32 100644 --- a/apps/web/src/components/sme/smeConversationConfig.ts +++ b/apps/web/src/components/sme/smeConversationConfig.ts @@ -3,12 +3,15 @@ import type { ProviderKind, SmeAuthMethod } from "@okcode/contracts"; export const SME_PROVIDER_LABELS: Record = { codex: "Codex / ChatGPT", claudeAgent: "Claude Code", + copilot: "GitHub Copilot", openclaw: "OpenClaw", }; export function getDefaultSmeAuthMethod(provider: ProviderKind): SmeAuthMethod { switch (provider) { case "claudeAgent": + return "apiKey"; + case "copilot": return "auto"; case "codex": return "chatgpt"; @@ -27,6 +30,8 @@ export function getSmeAuthMethodOptions( { value: "authToken", label: "Auth Token" }, { value: "auto", label: "CLI" }, ]; + case "copilot": + return [{ value: "auto", label: "Auto" }]; case "codex": return [ { value: "chatgpt", label: "ChatGPT OAuth" }, diff --git a/apps/web/src/lib/providerAvailability.ts b/apps/web/src/lib/providerAvailability.ts index 9c12891d..8630277c 100644 --- a/apps/web/src/lib/providerAvailability.ts +++ b/apps/web/src/lib/providerAvailability.ts @@ -1,10 +1,16 @@ import type { ProviderKind, ServerProviderStatus } from "@okcode/contracts"; -const THREAD_PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "openclaw"]; +const THREAD_PROVIDER_ORDER: readonly ProviderKind[] = [ + "codex", + "claudeAgent", + "copilot", + "openclaw", +]; const THREAD_PROVIDER_LABELS: Record = { codex: "Codex", claudeAgent: "Claude Code", + copilot: "GitHub Copilot", openclaw: "OpenClaw", }; diff --git a/apps/web/src/routes/_chat.settings.index.tsx b/apps/web/src/routes/_chat.settings.index.tsx index 2db0bdde..d4e9c5a8 100644 --- a/apps/web/src/routes/_chat.settings.index.tsx +++ b/apps/web/src/routes/_chat.settings.index.tsx @@ -222,14 +222,14 @@ function formatOpenclawGatewayDebugReport(result: TestOpenclawGatewayResult): st return lines.join("\n"); } -type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath"; +type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath" | "copilotBinaryPath"; type InstallProviderSettings = { provider: ProviderKind; title: string; binaryPathKey: InstallBinarySettingsKey; binaryPlaceholder: string; binaryDescription: ReactNode; - homePathKey?: "codexHomePath"; + homePathKey?: "codexHomePath" | "copilotConfigDir"; homePlaceholder?: string; homeDescription?: ReactNode; }; @@ -262,6 +262,21 @@ const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ ), }, + { + provider: "copilot", + title: "GitHub Copilot", + binaryPathKey: "copilotBinaryPath", + binaryPlaceholder: "GitHub Copilot binary path", + binaryDescription: ( + <> + Leave blank to use copilot from your PATH. Authentication uses{" "} + copilot login or GitHub CLI credentials. + + ), + homePathKey: "copilotConfigDir", + homePlaceholder: "Copilot config directory", + homeDescription: "Optional custom Copilot config directory.", + }, ]; const PROVIDER_AUTH_GUIDES: Record< @@ -285,6 +300,12 @@ const PROVIDER_AUTH_GUIDES: Record< verifyCmd: "claude auth status", note: "Claude Code must be installed and signed in before it appears in the thread picker.", }, + copilot: { + installCmd: "npm install -g @github/copilot", + authCmd: "copilot login", + verifyCmd: "copilot auth status", + note: "GitHub Copilot must be installed and signed in before it appears in the thread picker.", + }, openclaw: { verifyCmd: "Test Connection", note: "OpenClaw uses the gateway URL and password below rather than a local CLI login. A configured gateway unlocks it for new-thread selection.", @@ -470,6 +491,7 @@ function SettingsRouteView() { const [openInstallProviders, setOpenInstallProviders] = useState>({ codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), claudeAgent: Boolean(settings.claudeBinaryPath), + copilot: Boolean(settings.copilotBinaryPath || settings.copilotConfigDir), openclaw: Boolean(settings.openclawGatewayUrl || settings.openclawPassword), }); const [selectedCustomModelProvider, setSelectedCustomModelProvider] = @@ -479,6 +501,7 @@ function SettingsRouteView() { >({ codex: "", claudeAgent: "", + copilot: "", openclaw: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< @@ -542,7 +565,11 @@ function SettingsRouteView() { )!; const selectedCustomModelInput = customModelInputByProvider[selectedCustomModelProvider]; const selectedCustomModelError = customModelErrorByProvider[selectedCustomModelProvider] ?? null; - const totalCustomModels = settings.customCodexModels.length + settings.customClaudeModels.length; + const totalCustomModels = + settings.customCodexModels.length + + settings.customClaudeModels.length + + settings.customCopilotModels.length + + settings.customOpenClawModels.length; const activeProjectEnvironmentVariables = selectedProjectEnvironmentVariablesQuery.data?.entries; const savedCustomModelRows = MODEL_PROVIDER_SETTINGS.flatMap((providerSettings) => getCustomModelsForProvider(settings, providerSettings.provider).map((slug) => ({ @@ -557,6 +584,8 @@ function SettingsRouteView() { : savedCustomModelRows.slice(0, 5); const isInstallSettingsDirty = settings.claudeBinaryPath !== defaults.claudeBinaryPath || + settings.copilotBinaryPath !== defaults.copilotBinaryPath || + settings.copilotConfigDir !== defaults.copilotConfigDir || settings.codexBinaryPath !== defaults.codexBinaryPath || settings.codexHomePath !== defaults.codexHomePath; const isOpenClawSettingsDirty = @@ -1392,10 +1421,13 @@ function SettingsRouteView() { claudeBinaryPath: defaults.claudeBinaryPath, codexBinaryPath: defaults.codexBinaryPath, codexHomePath: defaults.codexHomePath, + copilotBinaryPath: defaults.copilotBinaryPath, + copilotConfigDir: defaults.copilotConfigDir, }); setOpenInstallProviders({ codex: false, claudeAgent: false, + copilot: false, openclaw: false, }); }} @@ -1411,11 +1443,16 @@ function SettingsRouteView() { providerSettings.provider === "codex" ? settings.codexBinaryPath !== defaults.codexBinaryPath || settings.codexHomePath !== defaults.codexHomePath - : settings.claudeBinaryPath !== defaults.claudeBinaryPath; + : providerSettings.provider === "claudeAgent" + ? settings.claudeBinaryPath !== defaults.claudeBinaryPath + : settings.copilotBinaryPath !== defaults.copilotBinaryPath || + settings.copilotConfigDir !== defaults.copilotConfigDir; const binaryPathValue = - providerSettings.binaryPathKey === "claudeBinaryPath" + providerSettings.provider === "claudeAgent" ? claudeBinaryPath - : codexBinaryPath; + : providerSettings.provider === "copilot" + ? settings.copilotBinaryPath + : codexBinaryPath; return ( - CODEX_HOME path + {providerSettings.homePathKey === "codexHomePath" + ? "CODEX_HOME path" + : "Copilot config directory"} - updateSettings({ - codexHomePath: event.target.value, - }) + updateSettings( + providerSettings.homePathKey === "codexHomePath" + ? { codexHomePath: event.target.value } + : { copilotConfigDir: event.target.value }, + ) } placeholder={providerSettings.homePlaceholder} spellCheck={false} @@ -1879,7 +1926,7 @@ function SettingsRouteView() { 0 ? ( { - if (value !== "codex" && value !== "claudeAgent") { - return; - } - setSelectedCustomModelProvider(value); + setSelectedCustomModelProvider(value as ProviderKind); }} > ; export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; @@ -67,6 +93,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", claudeAgent: "claude-sonnet-4-6", openclaw: "default", + copilot: "gpt-5.3-codex", }; // Backward compatibility for existing Codex-only call sites. @@ -97,16 +124,55 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record; export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { codex: "high", claudeAgent: "high", openclaw: "high", + copilot: "high", } as const satisfies Record; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 04a9c454..ee935aed 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -29,7 +29,7 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } as const; -export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "openclaw"]); +export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "openclaw", "copilot"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -63,10 +63,16 @@ export const OpenClawProviderStartOptions = Schema.Struct({ password: Schema.optional(TrimmedNonEmptyString), }); +export const CopilotProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + configDir: Schema.optional(TrimmedNonEmptyString), +}); + export const ProviderStartOptions = Schema.Struct({ codex: Schema.optional(CodexProviderStartOptions), claudeAgent: Schema.optional(ClaudeProviderStartOptions), openclaw: Schema.optional(OpenClawProviderStartOptions), + copilot: Schema.optional(CopilotProviderStartOptions), }); export type ProviderStartOptions = typeof ProviderStartOptions.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 64c5f68a..c96fa6f3 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -25,6 +25,7 @@ const RuntimeEventRawSource = Schema.Literals([ "claude.sdk.permission", "codex.sdk.thread-event", "openclaw.gateway.notification", + "copilot.sdk.event", "openclaw.gateway.event", "openclaw.gateway.response", ]); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 9da54df2..36bed69d 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,5 +1,6 @@ import { CLAUDE_CODE_EFFORT_OPTIONS, + COPILOT_REASONING_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS, OPENCLAW_REASONING_EFFORT_OPTIONS, DEFAULT_MODEL_BY_PROVIDER, @@ -9,6 +10,8 @@ import { REASONING_EFFORT_OPTIONS_BY_PROVIDER, type ClaudeModelOptions, type ClaudeCodeEffort, + type CopilotModelOptions, + type CopilotReasoningEffort, type CodexModelOptions, type CodexReasoningEffort, type OpenClawReasoningEffort, @@ -21,6 +24,7 @@ const MODEL_SLUG_SET_BY_PROVIDER: Record> = claudeAgent: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeAgent.map((option) => option.slug)), codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), openclaw: new Set(), + copilot: new Set(MODEL_OPTIONS_BY_PROVIDER.copilot.map((option) => option.slug)), }; const CLAUDE_OPUS_4_6_MODEL = "claude-opus-4-6"; @@ -83,7 +87,9 @@ export function normalizeModelSlug( ? trimmed.slice("anthropic/".length) : provider === "openclaw" && trimmed.toLowerCase().startsWith("openclaw/") ? trimmed.slice("openclaw/".length) - : trimmed; + : provider === "copilot" && trimmed.toLowerCase().startsWith("copilot/") + ? trimmed.slice("copilot/".length) + : trimmed; const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] as Record; const aliased = Object.prototype.hasOwnProperty.call(aliases, providerNormalized) @@ -160,10 +166,16 @@ export function inferProviderForModel( return "codex"; } + const normalizedCopilot = normalizeModelSlug(model, "copilot"); + if (normalizedCopilot && MODEL_SLUG_SET_BY_PROVIDER.copilot.has(normalizedCopilot)) { + return "copilot"; + } + if (typeof model === "string") { const trimmed = model.trim(); if (trimmed.startsWith("claude-")) return "claudeAgent"; if (trimmed.startsWith("openclaw/")) return "openclaw"; + if (trimmed.startsWith("copilot/")) return "copilot"; } return fallback; } @@ -176,6 +188,9 @@ export function getReasoningEffortOptions( export function getReasoningEffortOptions( provider: "openclaw", ): ReadonlyArray; +export function getReasoningEffortOptions( + provider: "copilot", +): ReadonlyArray; export function getReasoningEffortOptions( provider?: ProviderKind, model?: string | null | undefined, @@ -199,6 +214,7 @@ export function getReasoningEffortOptions( export function getDefaultReasoningEffort(provider: "codex"): CodexReasoningEffort; export function getDefaultReasoningEffort(provider: "claudeAgent"): ClaudeCodeEffort; export function getDefaultReasoningEffort(provider: "openclaw"): OpenClawReasoningEffort; +export function getDefaultReasoningEffort(provider: "copilot"): CopilotReasoningEffort; export function getDefaultReasoningEffort(provider?: ProviderKind): ProviderReasoningEffort; export function getDefaultReasoningEffort( provider: ProviderKind = "codex", @@ -218,6 +234,10 @@ export function resolveReasoningEffortForProvider( provider: "openclaw", effort: string | null | undefined, ): OpenClawReasoningEffort | null; +export function resolveReasoningEffortForProvider( + provider: "copilot", + effort: string | null | undefined, +): CopilotReasoningEffort | null; export function resolveReasoningEffortForProvider( provider: ProviderKind, effort: string | null | undefined, @@ -289,6 +309,18 @@ export function normalizeClaudeModelOptions( return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } +export function normalizeCopilotModelOptions( + modelOptions: CopilotModelOptions | null | undefined, +): CopilotModelOptions | undefined { + const defaultReasoningEffort = getDefaultReasoningEffort("copilot"); + const reasoningEffort = + resolveReasoningEffortForProvider("copilot", modelOptions?.reasoningEffort) ?? + defaultReasoningEffort; + const nextOptions: CopilotModelOptions = + reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + export function applyClaudePromptEffortPrefix( text: string, effort: ClaudeCodeEffort | null | undefined, @@ -308,6 +340,7 @@ export function applyClaudePromptEffortPrefix( export { CLAUDE_CODE_EFFORT_OPTIONS, + COPILOT_REASONING_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS, OPENCLAW_REASONING_EFFORT_OPTIONS, };