diff --git a/.changeset/provider-capabilities-contract.md b/.changeset/provider-capabilities-contract.md
new file mode 100644
index 000000000..405560f21
--- /dev/null
+++ b/.changeset/provider-capabilities-contract.md
@@ -0,0 +1,5 @@
+---
+"helmor": patch
+---
+
+Replace scattered `provider === "codex"` / `provider === "cursor"` checks with a data-driven provider-capability table exposed through a new `list_provider_capabilities` command, so adding a new provider becomes a single matrix edit instead of a codebase-wide grep.
diff --git a/sidecar/bun.lock b/sidecar/bun.lock
index 6ca092c03..9b01b01f8 100644
--- a/sidecar/bun.lock
+++ b/sidecar/bun.lock
@@ -5,6 +5,7 @@
"": {
"name": "@helmor/sidecar",
"dependencies": {
+ "@agentclientprotocol/sdk": "^0.21.1",
"@anthropic-ai/claude-agent-sdk": "0.2.139",
"@anthropic-ai/claude-code": "2.1.139",
"@cursor/sdk": "^1.0.12",
@@ -20,6 +21,8 @@
"@anthropic-ai/claude-code",
],
"packages": {
+ "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ZTLH+o9QxcZDLX/9ww+W7C2iExnXFM+vD/uGFVSlR61Kzj9FaxUqBC6Rv/kwgA7qVWYUEI9c5ZNqCuO9PM4rKg=="],
+
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.139", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.139", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.139", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.139", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.139", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.139", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.139", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.139", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.139" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-9zmitYoxCQiQZsTUbm9IGC6VyZt70J3NLtkRQPQvFVfz7bKDrhlZZKzXmyl2XmqedXEIeQy2ACmwdjwzPIVIAw=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.139", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dnuO2E0x6o9GAk9iZZKlEd10h+0PQFdTfr5aQU4I0W+0ReKsFEoE9LAqfomS2EvLUQ9L62X0+n0iyZQmAVi1kw=="],
diff --git a/sidecar/package.json b/sidecar/package.json
index aa5ec1316..0a1b8ab82 100644
--- a/sidecar/package.json
+++ b/sidecar/package.json
@@ -13,6 +13,7 @@
"typecheck": "bunx tsc --noEmit"
},
"dependencies": {
+ "@agentclientprotocol/sdk": "^0.21.1",
"@anthropic-ai/claude-agent-sdk": "0.2.139",
"@anthropic-ai/claude-code": "2.1.139",
"@cursor/sdk": "^1.0.12",
diff --git a/sidecar/src/copilot-session-manager.ts b/sidecar/src/copilot-session-manager.ts
new file mode 100644
index 000000000..2f4839cd2
--- /dev/null
+++ b/sidecar/src/copilot-session-manager.ts
@@ -0,0 +1,701 @@
+/**
+ * SessionManager backed by GitHub Copilot CLI in ACP mode.
+ *
+ * Spawns `copilot --acp` as a child process per session, communicates
+ * via newline-delimited JSON-RPC 2.0 on stdin/stdout (the ACP transport).
+ * Streaming SessionUpdate notifications are forwarded as `copilot/`-prefixed
+ * passthrough events so the Rust accumulator can handle them uniformly.
+ */
+
+import {
+ type ChildProcessWithoutNullStreams,
+ execFile,
+ spawn,
+} from "node:child_process";
+import { existsSync } from "node:fs";
+import { createInterface } from "node:readline";
+import { promisify } from "node:util";
+import type { SidecarEmitter } from "./emitter.js";
+import { errorDetails, logger } from "./logger.js";
+import { listProviderModels } from "./model-catalog.js";
+import type {
+ GenerateTitleOptions,
+ ListSlashCommandsParams,
+ ProviderModelInfo,
+ SendMessageParams,
+ SessionManager,
+ SlashCommandInfo,
+ UserInputResolution,
+} from "./session-manager.js";
+
+function resolveCopilotBinPath(): string {
+ const override = process.env.HELMOR_COPILOT_BIN_PATH;
+ if (override && existsSync(override)) return override;
+ return "copilot";
+}
+
+const COPILOT_BIN_PATH = resolveCopilotBinPath();
+
+interface AcpPendingRequest {
+ resolve: (value: unknown) => void;
+ reject: (error: Error) => void;
+ timeout: ReturnType;
+}
+
+interface CopilotSession {
+ child: ChildProcessWithoutNullStreams;
+ sessionId: string | null;
+ pendingRequests: Map;
+ nextId: number;
+ activeRequestId: string | null;
+ activeEmitter: SidecarEmitter | null;
+ aborted: boolean;
+ modelId: string;
+}
+
+const ACP_REQUEST_TIMEOUT_MS = 30_000;
+
+export class CopilotSessionManager implements SessionManager {
+ private sessions = new Map();
+ private pendingPermissions = new Map<
+ string,
+ { helmorSessionId: string; jsonRpcId: number }
+ >();
+
+ resolveUserInput(
+ _userInputId: string,
+ _resolution: UserInputResolution,
+ ): boolean {
+ return false;
+ }
+
+ resolvePermission(permissionId: string, behavior: "allow" | "deny"): void {
+ const pending = this.pendingPermissions.get(permissionId);
+ if (!pending) return;
+ this.pendingPermissions.delete(permissionId);
+
+ const ctx = this.sessions.get(pending.helmorSessionId);
+ if (!ctx) return;
+
+ const result = { approved: behavior === "allow" };
+ this.sendJsonRpcResponse(ctx, pending.jsonRpcId, result);
+ logger.debug("Copilot permission resolved", { permissionId, behavior });
+ }
+
+ async sendMessage(
+ requestId: string,
+ params: SendMessageParams,
+ emitter: SidecarEmitter,
+ ): Promise {
+ const { sessionId, prompt, model, cwd, effortLevel } = params;
+ const workDir = cwd ?? process.cwd();
+ const modelId = model ?? "gpt-4o";
+
+ let ctx = this.sessions.get(sessionId);
+ if (!ctx) {
+ try {
+ ctx = await this.spawnSession(sessionId, workDir, modelId, effortLevel);
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : String(error);
+ logger.error(
+ `[${requestId}] Copilot spawn failed: ${msg}`,
+ errorDetails(error),
+ );
+ emitter.error(requestId, `Copilot: ${msg}`);
+ emitter.end(requestId);
+ return;
+ }
+ }
+
+ ctx.activeRequestId = requestId;
+ ctx.activeEmitter = emitter;
+ ctx.aborted = false;
+ ctx.modelId = modelId;
+
+ emitter.passthrough(requestId, {
+ type: "copilot/session_init",
+ session_id: ctx.sessionId ?? sessionId,
+ model: modelId,
+ });
+
+ try {
+ emitter.passthrough(requestId, {
+ type: "copilot/status",
+ status: "RUNNING",
+ });
+
+ await this.sendAcpRequest(
+ ctx,
+ "session/prompt",
+ {
+ sessionId: ctx.sessionId,
+ prompt: [{ type: "text", text: prompt }],
+ },
+ 0,
+ );
+
+ emitter.passthrough(requestId, {
+ type: "copilot/status",
+ status: "FINISHED",
+ });
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : String(error);
+ if (ctx.aborted) {
+ logger.debug(`[${requestId}] Copilot stream aborted by user`);
+ } else {
+ logger.error(
+ `[${requestId}] Copilot prompt failed: ${msg}`,
+ errorDetails(error),
+ );
+ emitter.error(requestId, `Copilot: ${msg}`);
+ }
+ } finally {
+ ctx.activeRequestId = null;
+ ctx.activeEmitter = null;
+ }
+
+ if (ctx.aborted) {
+ emitter.aborted(requestId, "user_requested");
+ }
+ emitter.end(requestId);
+ }
+
+ async generateTitle(
+ _requestId: string,
+ _userMessage: string,
+ _branchRenamePrompt: string | null,
+ _emitter: SidecarEmitter,
+ _timeoutMs?: number,
+ _options?: GenerateTitleOptions,
+ ): Promise {
+ // Copilot doesn't have a lightweight title-gen path; delegate to
+ // Claude/Codex via the fallback chain in index.ts by throwing.
+ throw new Error("Copilot does not support title generation");
+ }
+
+ async listSlashCommands(
+ _params: ListSlashCommandsParams,
+ ): Promise {
+ return [];
+ }
+
+ async listModels(_opts?: {
+ apiKey?: string;
+ }): Promise {
+ try {
+ const token = await this.getGhToken();
+ if (!token) throw new Error("No gh auth token available");
+
+ const response = await fetch(
+ "https://api.individual.githubcopilot.com/models",
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Copilot-Integration-Id": "copilot-developer-cli",
+ },
+ signal: AbortSignal.timeout(15_000),
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`Copilot API returned ${response.status}`);
+ }
+
+ const body = (await response.json()) as {
+ data?: Array<{
+ id: string;
+ name: string;
+ model_picker_enabled?: boolean;
+ model_picker_category?: string;
+ capabilities?: {
+ type?: string;
+ supports?: { reasoning_effort?: string[] };
+ };
+ }>;
+ };
+
+ const models = (body.data ?? []).filter(
+ (m) =>
+ m.model_picker_enabled === true &&
+ (m.capabilities?.type === "chat" || !m.capabilities?.type),
+ );
+
+ if (models.length > 0) {
+ return models.map((m) => ({
+ id: m.id,
+ label: m.name,
+ cliModel: m.id,
+ effortLevels:
+ m.capabilities?.supports?.reasoning_effort?.filter(
+ (e) => e !== "none",
+ ) ?? [],
+ }));
+ }
+ } catch (err) {
+ logger.debug(
+ `Copilot API models fetch failed, using static catalog: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ return listProviderModels("copilot");
+ }
+
+ private ghTokenCache: { token: string; expiresAt: number } | null = null;
+
+ private async getGhToken(): Promise {
+ if (this.ghTokenCache && this.ghTokenCache.expiresAt > Date.now()) {
+ return this.ghTokenCache.token;
+ }
+ try {
+ const ghPath = process.env.HELMOR_GH_BIN_PATH || "gh";
+ const result = await promisify(execFile)(ghPath, ["auth", "token"], {
+ timeout: 5_000,
+ });
+ const token = result.stdout.trim();
+ if (token) {
+ this.ghTokenCache = {
+ token,
+ expiresAt: Date.now() + 5 * 60 * 1000,
+ };
+ return token;
+ }
+ } catch {
+ // gh not available or not logged in
+ }
+ return null;
+ }
+
+ async stopSession(sessionId: string): Promise {
+ const ctx = this.sessions.get(sessionId);
+ if (!ctx) return;
+ ctx.aborted = true;
+
+ if (ctx.sessionId) {
+ try {
+ this.sendJsonRpcNotification(ctx, "session/cancel", {
+ sessionId: ctx.sessionId,
+ });
+ } catch {
+ // best effort
+ }
+ }
+
+ ctx.activeRequestId = null;
+ ctx.activeEmitter = null;
+ }
+
+ async steer(
+ _sessionId: string,
+ _prompt: string,
+ _files: readonly string[],
+ _images: readonly string[],
+ ): Promise {
+ return false;
+ }
+
+ async shutdown(): Promise {
+ for (const [, ctx] of this.sessions) {
+ try {
+ ctx.child.kill("SIGTERM");
+ } catch {
+ // already dead
+ }
+ }
+ this.sessions.clear();
+ }
+
+ // ── Private helpers ─────────────────────────────────────────────────
+
+ private async spawnSession(
+ helmorSessionId: string,
+ cwd: string,
+ modelId: string,
+ effortLevel?: string,
+ ): Promise {
+ const args = ["--acp"];
+ if (effortLevel) {
+ args.push("--reasoning-effort", effortLevel);
+ }
+ const child = spawn(COPILOT_BIN_PATH, args, {
+ cwd,
+ stdio: ["pipe", "pipe", "pipe"],
+ env: { ...process.env },
+ });
+
+ const ctx: CopilotSession = {
+ child,
+ sessionId: null,
+ pendingRequests: new Map(),
+ nextId: 1,
+ activeRequestId: null,
+ activeEmitter: null,
+ aborted: false,
+ modelId,
+ };
+
+ const rl = createInterface({ input: child.stdout });
+ rl.on("line", (line) => {
+ this.handleLine(helmorSessionId, ctx, line);
+ });
+
+ child.stderr.on("data", (chunk: Buffer) => {
+ logger.debug(
+ `[copilot:${helmorSessionId}] stderr: ${chunk.toString().trim()}`,
+ );
+ });
+
+ child.on("exit", (code, signal) => {
+ logger.info(`[copilot:${helmorSessionId}] exited`, { code, signal });
+ this.sessions.delete(helmorSessionId);
+ for (const [, req] of ctx.pendingRequests) {
+ clearTimeout(req.timeout);
+ req.reject(new Error(`Copilot process exited (code=${code})`));
+ }
+ ctx.pendingRequests.clear();
+ });
+
+ try {
+ await this.sendAcpRequest(ctx, "initialize", {
+ protocolVersion: 1,
+ clientCapabilities: {
+ fs: { readTextFile: true, writeTextFile: true },
+ },
+ });
+
+ const sessionResult = (await this.sendAcpRequest(ctx, "session/new", {
+ cwd,
+ mcpServers: [],
+ })) as { sessionId?: string };
+
+ ctx.sessionId = sessionResult?.sessionId ?? helmorSessionId;
+ } catch (err) {
+ try {
+ child.kill("SIGTERM");
+ } catch {
+ // already dead
+ }
+ throw err;
+ }
+
+ this.sessions.set(helmorSessionId, ctx);
+ return ctx;
+ }
+
+ private handleLine(
+ helmorSessionId: string,
+ ctx: CopilotSession,
+ line: string,
+ ): void {
+ if (!line.trim()) return;
+
+ let msg: Record;
+ try {
+ msg = JSON.parse(line);
+ } catch {
+ logger.debug(
+ `[copilot:${helmorSessionId}] non-JSON line: ${line.slice(0, 100)}`,
+ );
+ return;
+ }
+
+ // JSON-RPC response (has `id` + `result` or `error`)
+ if ("id" in msg && ("result" in msg || "error" in msg)) {
+ const id = msg.id as number;
+ const pending = ctx.pendingRequests.get(id);
+ if (pending) {
+ ctx.pendingRequests.delete(id);
+ clearTimeout(pending.timeout);
+ if ("error" in msg) {
+ const err = msg.error as { message?: string };
+ pending.reject(new Error(err?.message ?? "ACP error"));
+ } else {
+ pending.resolve(msg.result);
+ }
+ }
+ return;
+ }
+
+ // JSON-RPC request from agent (has `id` + `method`)
+ if ("id" in msg && "method" in msg) {
+ this.handleAgentRequest(helmorSessionId, ctx, msg);
+ return;
+ }
+
+ // JSON-RPC notification (has `method`, no `id`)
+ if ("method" in msg && !("id" in msg)) {
+ this.handleNotification(helmorSessionId, ctx, msg);
+ return;
+ }
+ }
+
+ private handleAgentRequest(
+ helmorSessionId: string,
+ ctx: CopilotSession,
+ msg: Record,
+ ): void {
+ const method = msg.method as string;
+ const id = msg.id as number;
+ const params = (msg.params ?? {}) as Record;
+
+ if (method === "session/request_permission") {
+ const permissionId = `copilot-${helmorSessionId}-${id}`;
+ this.pendingPermissions.set(permissionId, {
+ helmorSessionId,
+ jsonRpcId: id,
+ });
+
+ const toolCall = (params.toolCall ?? {}) as Record;
+ const toolName =
+ (toolCall.title as string) ?? (toolCall.kind as string) ?? "tool";
+ const toolInput = (toolCall.rawInput as Record) ?? {};
+
+ if (ctx.activeRequestId && ctx.activeEmitter) {
+ ctx.activeEmitter.passthrough(ctx.activeRequestId, {
+ type: "permissionRequest",
+ permissionId,
+ toolName,
+ toolInput,
+ title: toolName,
+ description: "",
+ });
+ }
+ return;
+ }
+
+ if (method === "fs/read_text_file") {
+ void this.handleFsReadTextFile(ctx, id, params);
+ return;
+ }
+
+ if (method === "fs/write_text_file") {
+ void this.handleFsWriteTextFile(ctx, id, params);
+ return;
+ }
+
+ this.sendJsonRpcResponse(ctx, id, {});
+ }
+
+ private async handleFsReadTextFile(
+ ctx: CopilotSession,
+ id: number,
+ params: Record,
+ ): Promise {
+ const path = params.path as string;
+ const line = params.line as number | undefined;
+ const limit = params.limit as number | undefined;
+ try {
+ const fs = await import("node:fs/promises");
+ let content = await fs.readFile(path, "utf-8");
+ if (line !== undefined || limit !== undefined) {
+ const lines = content.split("\n");
+ const start = line ?? 0;
+ const end = limit !== undefined ? start + limit : lines.length;
+ content = lines.slice(start, end).join("\n");
+ }
+ this.sendJsonRpcResponse(ctx, id, { content });
+ } catch (err) {
+ this.sendJsonRpcError(ctx, id, {
+ code: -32603,
+ message: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+
+ private async handleFsWriteTextFile(
+ ctx: CopilotSession,
+ id: number,
+ params: Record,
+ ): Promise {
+ const path = params.path as string;
+ const content = params.content as string;
+ try {
+ const fs = await import("node:fs/promises");
+ await fs.writeFile(path, content, "utf-8");
+ this.sendJsonRpcResponse(ctx, id, {});
+ } catch (err) {
+ this.sendJsonRpcError(ctx, id, {
+ code: -32603,
+ message: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+
+ private handleNotification(
+ helmorSessionId: string,
+ ctx: CopilotSession,
+ msg: Record,
+ ): void {
+ const method = msg.method as string;
+ const params = (msg.params ?? {}) as Record;
+
+ if (!ctx.activeRequestId || !ctx.activeEmitter) return;
+
+ const requestId = ctx.activeRequestId;
+ const emitter = ctx.activeEmitter;
+
+ if (method !== "session/update") {
+ logger.debug(
+ `[copilot:${helmorSessionId}] unhandled notification: ${method}`,
+ );
+ return;
+ }
+
+ const update = (params.update ?? {}) as {
+ sessionUpdate?: string;
+ [key: string]: unknown;
+ };
+ const variant = update.sessionUpdate ?? "unknown";
+
+ const extractText = (block: unknown): string => {
+ if (!block || typeof block !== "object") return "";
+ const b = block as { type?: string; text?: unknown };
+ if (b.type === "text" && typeof b.text === "string") return b.text;
+ return "";
+ };
+
+ switch (variant) {
+ case "agent_message_chunk":
+ emitter.passthrough(requestId, {
+ type: "copilot/assistant",
+ text: extractText(update.content),
+ });
+ break;
+ case "agent_thought_chunk":
+ emitter.passthrough(requestId, {
+ type: "copilot/thinking",
+ text: extractText(update.content),
+ });
+ break;
+ case "user_message_chunk":
+ break;
+ case "tool_call": {
+ const callId =
+ (update.toolCallId as string) ??
+ (update.callId as string) ??
+ `tc-${Date.now()}`;
+ emitter.passthrough(requestId, {
+ type: "copilot/tool_call_start",
+ call_id: callId,
+ name: (update.title as string) ?? (update.kind as string) ?? "tool",
+ args: update.rawInput ?? {},
+ });
+ const status = update.status as string | undefined;
+ if (status === "completed" || status === "failed") {
+ emitter.passthrough(requestId, {
+ type: "copilot/tool_call_end",
+ call_id: callId,
+ result: update.content ?? update.rawOutput ?? null,
+ is_error: status === "failed",
+ });
+ }
+ break;
+ }
+ case "tool_call_update": {
+ const callId =
+ (update.toolCallId as string) ?? (update.callId as string) ?? "";
+ const status = update.status as string | undefined;
+ if (status === "completed" || status === "failed") {
+ emitter.passthrough(requestId, {
+ type: "copilot/tool_call_end",
+ call_id: callId,
+ result: update.content ?? update.rawOutput ?? null,
+ is_error: status === "failed",
+ });
+ } else {
+ emitter.passthrough(requestId, {
+ type: "copilot/tool_call_update",
+ call_id: callId,
+ output:
+ (update.content as string) ?? (update.rawOutput as string) ?? "",
+ });
+ }
+ break;
+ }
+ case "plan":
+ emitter.passthrough(requestId, {
+ type: "copilot/plan",
+ plan: update.entries ?? update.plan ?? [],
+ });
+ break;
+ case "available_commands_update":
+ case "current_mode_update":
+ break;
+ default:
+ logger.debug(
+ `[copilot:${helmorSessionId}] unhandled session/update variant: ${variant}`,
+ );
+ }
+ }
+
+ private sendAcpRequest(
+ ctx: CopilotSession,
+ method: string,
+ params: Record,
+ timeoutMs?: number,
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ const id = ctx.nextId++;
+ const effectiveTimeout = timeoutMs ?? ACP_REQUEST_TIMEOUT_MS;
+ const timeout =
+ effectiveTimeout > 0
+ ? setTimeout(() => {
+ ctx.pendingRequests.delete(id);
+ reject(new Error(`ACP request '${method}' timed out`));
+ }, effectiveTimeout)
+ : null;
+
+ ctx.pendingRequests.set(id, {
+ resolve,
+ reject,
+ timeout: timeout as ReturnType,
+ });
+
+ const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params });
+ try {
+ ctx.child.stdin.write(`${msg}\n`);
+ } catch (err) {
+ ctx.pendingRequests.delete(id);
+ if (timeout) clearTimeout(timeout);
+ reject(err instanceof Error ? err : new Error(String(err)));
+ }
+ });
+ }
+
+ private sendJsonRpcResponse(
+ ctx: CopilotSession,
+ id: number,
+ result: unknown,
+ ): void {
+ const msg = JSON.stringify({ jsonrpc: "2.0", id, result });
+ try {
+ ctx.child.stdin.write(`${msg}\n`);
+ } catch {
+ // pipe closed
+ }
+ }
+
+ private sendJsonRpcError(
+ ctx: CopilotSession,
+ id: number,
+ error: { code: number; message: string },
+ ): void {
+ const msg = JSON.stringify({ jsonrpc: "2.0", id, error });
+ try {
+ ctx.child.stdin.write(`${msg}\n`);
+ } catch {
+ // pipe closed
+ }
+ }
+
+ private sendJsonRpcNotification(
+ ctx: CopilotSession,
+ method: string,
+ params: Record,
+ ): void {
+ const msg = JSON.stringify({ jsonrpc: "2.0", method, params });
+ try {
+ ctx.child.stdin.write(`${msg}\n`);
+ } catch {
+ // pipe closed
+ }
+ }
+}
diff --git a/sidecar/src/index.ts b/sidecar/src/index.ts
index 6214ec604..5c128c8cc 100644
--- a/sidecar/src/index.ts
+++ b/sidecar/src/index.ts
@@ -13,6 +13,7 @@ import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk";
import { isAbortError } from "./abort.js";
import { ClaudeSessionManager } from "./claude-session-manager.js";
import { CodexAppServerManager } from "./codex-app-server-manager.js";
+import { CopilotSessionManager } from "./copilot-session-manager.js";
import { CursorSessionManager } from "./cursor-session-manager.js";
import { createSidecarEmitter } from "./emitter.js";
import { errorDetails, logger } from "./logger.js";
@@ -42,10 +43,12 @@ import {
const claudeManager = new ClaudeSessionManager();
const codexManager = new CodexAppServerManager();
+const copilotManager = new CopilotSessionManager();
const cursorManager = new CursorSessionManager();
const managers: Record = {
claude: claudeManager,
codex: codexManager,
+ copilot: copilotManager,
cursor: cursorManager,
};
diff --git a/sidecar/src/model-catalog.ts b/sidecar/src/model-catalog.ts
index d68320368..1d92f1ec3 100644
--- a/sidecar/src/model-catalog.ts
+++ b/sidecar/src/model-catalog.ts
@@ -75,6 +75,101 @@ const MODEL_CATALOG: Record = {
supportsFastMode: true,
},
],
+ // Static fallback — the dynamic fetch via Copilot API is the real
+ // source of truth. This list covers picker-enabled models as of
+ // May 2026 so the picker has something to show before the first fetch.
+ copilot: [
+ {
+ id: "claude-opus-4.7",
+ label: "Claude Opus 4.7",
+ cliModel: "claude-opus-4.7",
+ effortLevels: ["medium"],
+ },
+ {
+ id: "claude-sonnet-4.6",
+ label: "Claude Sonnet 4.6",
+ cliModel: "claude-sonnet-4.6",
+ effortLevels: ["low", "medium", "high"],
+ },
+ {
+ id: "claude-sonnet-4.5",
+ label: "Claude Sonnet 4.5",
+ cliModel: "claude-sonnet-4.5",
+ effortLevels: [],
+ },
+ {
+ id: "claude-opus-4.5",
+ label: "Claude Opus 4.5",
+ cliModel: "claude-opus-4.5",
+ effortLevels: [],
+ },
+ {
+ id: "claude-haiku-4.5",
+ label: "Claude Haiku 4.5",
+ cliModel: "claude-haiku-4.5",
+ effortLevels: [],
+ },
+ {
+ id: "gemini-2.5-pro",
+ label: "Gemini 2.5 Pro",
+ cliModel: "gemini-2.5-pro",
+ effortLevels: [],
+ },
+ {
+ id: "gpt-5.5",
+ label: "GPT-5.5",
+ cliModel: "gpt-5.5",
+ effortLevels: ["low", "medium", "high", "xhigh"],
+ },
+ {
+ id: "gpt-5.4",
+ label: "GPT-5.4",
+ cliModel: "gpt-5.4",
+ effortLevels: ["low", "medium", "high", "xhigh"],
+ },
+ {
+ id: "gpt-5.4-mini",
+ label: "GPT-5.4 mini",
+ cliModel: "gpt-5.4-mini",
+ effortLevels: ["low", "medium", "high", "xhigh"],
+ },
+ {
+ id: "gpt-5.3-codex",
+ label: "GPT-5.3-Codex",
+ cliModel: "gpt-5.3-codex",
+ effortLevels: ["low", "medium", "high", "xhigh"],
+ },
+ {
+ id: "gpt-5.2-codex",
+ label: "GPT-5.2-Codex",
+ cliModel: "gpt-5.2-codex",
+ effortLevels: ["low", "medium", "high", "xhigh"],
+ },
+ {
+ id: "gpt-5.2",
+ label: "GPT-5.2",
+ cliModel: "gpt-5.2",
+ effortLevels: ["low", "medium", "high", "xhigh"],
+ },
+ {
+ id: "gpt-5-mini",
+ label: "GPT-5 mini",
+ cliModel: "gpt-5-mini",
+ effortLevels: ["low", "medium", "high"],
+ },
+ {
+ id: "gpt-4.1",
+ label: "GPT-4.1",
+ cliModel: "gpt-4.1",
+ effortLevels: [],
+ },
+ {
+ id: "gpt-4o",
+ label: "GPT-4o",
+ cliModel: "gpt-4o",
+ effortLevels: [],
+ },
+ ],
// Static fallback only — `CursorSessionManager.listModels` hits the live
// `Cursor.models.list` API for the full set with up-to-date capability
// metadata. This list is what shows when the API key isn't configured
diff --git a/sidecar/src/request-parser.ts b/sidecar/src/request-parser.ts
index 986cb3ba2..ec081343d 100644
--- a/sidecar/src/request-parser.ts
+++ b/sidecar/src/request-parser.ts
@@ -83,7 +83,12 @@ export function optionalObject(
}
export function parseProvider(value: unknown): Provider {
- if (value === "claude" || value === "codex" || value === "cursor")
+ if (
+ value === "claude" ||
+ value === "codex" ||
+ value === "cursor" ||
+ value === "copilot"
+ )
return value;
throw new Error(`unknown provider: ${String(value)}`);
}
diff --git a/sidecar/src/session-manager.ts b/sidecar/src/session-manager.ts
index cd19db7f5..398bad7d2 100644
--- a/sidecar/src/session-manager.ts
+++ b/sidecar/src/session-manager.ts
@@ -7,7 +7,7 @@
import type { SidecarEmitter } from "./emitter.js";
-export type Provider = "claude" | "codex" | "cursor";
+export type Provider = "claude" | "codex" | "cursor" | "copilot";
export interface SendMessageParams {
readonly sessionId: string;
diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs
index 0e4a3d003..84e318d1c 100644
--- a/src-tauri/src/agents.rs
+++ b/src-tauri/src/agents.rs
@@ -13,6 +13,7 @@ mod catalog;
pub(crate) mod claude_project_files;
mod custom_providers;
mod persistence;
+pub mod provider_capabilities;
mod queries;
mod slash_commands;
pub(crate) mod streaming;
@@ -205,6 +206,20 @@ pub async fn list_agent_model_sections() -> CmdResult> {
Ok(queries::fetch_agent_model_sections())
}
+/// Return the provider-capability table for every provider Helmor
+/// ships today. Static — no DB hit, no IPC fan-out — so callers are
+/// expected to cache the result for the lifetime of the app. Drives
+/// the composer's feature-flag branches (active-goal interception,
+/// permission-mode dropdown, etc.).
+#[tauri::command]
+pub async fn list_provider_capabilities(
+) -> CmdResult> {
+ Ok(provider_capabilities::KNOWN_PROVIDERS
+ .iter()
+ .map(|p| provider_capabilities::capabilities_for_provider(p))
+ .collect())
+}
+
#[tauri::command]
pub async fn list_cursor_models(
sidecar: tauri::State<'_, crate::sidecar::ManagedSidecar>,
@@ -214,6 +229,13 @@ pub async fn list_cursor_models(
queries::fetch_cursor_models(sidecar.inner(), api_key)
}
+#[tauri::command]
+pub async fn list_copilot_models(
+ sidecar: tauri::State<'_, crate::sidecar::ManagedSidecar>,
+) -> CmdResult> {
+ queries::fetch_copilot_models(sidecar.inner())
+}
+
#[tauri::command]
pub async fn send_agent_message_stream(
app: AppHandle,
diff --git a/src-tauri/src/agents/catalog.rs b/src-tauri/src/agents/catalog.rs
index a0077220e..26ff2c300 100644
--- a/src-tauri/src/agents/catalog.rs
+++ b/src-tauri/src/agents/catalog.rs
@@ -56,6 +56,7 @@ fn model_sections_for_inputs(
.extend(custom_provider_options(custom));
let mut sections = vec![claude_section];
sections.push(codex_section());
+ sections.push(copilot_section());
sections.push(cursor_section_from_prefs(cursor_prefs));
sections
@@ -101,6 +102,47 @@ fn codex_section() -> AgentModelSection {
}
}
+fn copilot_section() -> AgentModelSection {
+ AgentModelSection {
+ id: "copilot".to_string(),
+ label: "Copilot".to_string(),
+ status: AgentModelSectionStatus::Ready,
+ options: vec![
+ copilot_model_with_effort("claude-opus-4.7", "Claude Opus 4.7", &["medium"]),
+ copilot_model_with_effort(
+ "claude-sonnet-4.6",
+ "Claude Sonnet 4.6",
+ &["low", "medium", "high"],
+ ),
+ copilot_model_with_effort("claude-sonnet-4.5", "Claude Sonnet 4.5", &[]),
+ copilot_model_with_effort("claude-opus-4.5", "Claude Opus 4.5", &[]),
+ copilot_model_with_effort("claude-haiku-4.5", "Claude Haiku 4.5", &[]),
+ copilot_model_with_effort("gemini-2.5-pro", "Gemini 2.5 Pro", &[]),
+ copilot_model_with_effort("gpt-5.5", "GPT-5.5", &["low", "medium", "high", "xhigh"]),
+ copilot_model_with_effort("gpt-5.4", "GPT-5.4", &["low", "medium", "high", "xhigh"]),
+ copilot_model_with_effort(
+ "gpt-5.4-mini",
+ "GPT-5.4 mini",
+ &["low", "medium", "high", "xhigh"],
+ ),
+ copilot_model_with_effort(
+ "gpt-5.3-codex",
+ "GPT-5.3-Codex",
+ &["low", "medium", "high", "xhigh"],
+ ),
+ copilot_model_with_effort(
+ "gpt-5.2-codex",
+ "GPT-5.2-Codex",
+ &["low", "medium", "high", "xhigh"],
+ ),
+ copilot_model_with_effort("gpt-5.2", "GPT-5.2", &["low", "medium", "high", "xhigh"]),
+ copilot_model_with_effort("gpt-5-mini", "GPT-5 mini", &["low", "medium", "high"]),
+ copilot_model_with_effort("gpt-4.1", "GPT-4.1", &[]),
+ copilot_model_with_effort("gpt-4o", "GPT-4o", &[]),
+ ],
+ }
+}
+
/// Cursor picker section, driven by `app.cursor_provider` settings:
/// `enabledModelIds` (user picks; `null` → auto-fill on next fetch) and
/// `cachedModels` (last `Cursor.models.list` snapshot). When both are
@@ -322,6 +364,39 @@ fn codex_model(id: &str, label: &str) -> AgentModelOption {
}
}
+#[allow(dead_code)]
+fn copilot_model(wire_id: &str, label: &str) -> AgentModelOption {
+ copilot_model_with_effort(wire_id, label, &["low", "medium", "high", "xhigh"])
+}
+
+fn copilot_model_with_effort(
+ wire_id: &str,
+ label: &str,
+ effort_levels: &[&str],
+) -> AgentModelOption {
+ AgentModelOption {
+ id: namespaced_copilot_id(wire_id),
+ provider: "copilot".to_string(),
+ label: label.to_string(),
+ cli_model: wire_id.to_string(),
+ provider_key: None,
+ effort_levels: effort_levels
+ .iter()
+ .map(|level| level.to_string())
+ .collect(),
+ supports_fast_mode: false,
+ supports_context_usage: false,
+ }
+}
+
+fn namespaced_copilot_id(wire_id: &str) -> String {
+ if wire_id.starts_with("copilot-") {
+ wire_id.to_string()
+ } else {
+ format!("copilot-{wire_id}")
+ }
+}
+
/// Build a Cursor option. Cursor wire ids collide with claude/codex
/// (e.g. `default` = Claude Opus), so Helmor `id` is namespaced
/// `cursor-`; `cli_model` keeps the bare wire id for `agent.send`.
@@ -395,18 +470,25 @@ pub fn resolve_model(model_id: &str, provider_hint: Option<&str>) -> ResolvedMod
Some("cursor") => "cursor",
Some("codex") => "codex",
Some("claude") => "claude",
+ Some("copilot") => "copilot",
+ _ if model_id.starts_with("copilot-") => "copilot",
_ if model_id.starts_with("cursor-") => "cursor",
_ if model_id.starts_with("composer-") => "cursor",
_ if model_id.starts_with("gpt-") => "codex",
_ => "claude",
};
- // Strip `cursor-` for SDK; `composer-*` had no prefix.
+ // Strip namespace prefix for SDK; bare wire id is what the CLI expects.
let cli_model = if provider == "cursor" {
model_id
.strip_prefix("cursor-")
.unwrap_or(model_id)
.to_string()
+ } else if provider == "copilot" {
+ model_id
+ .strip_prefix("copilot-")
+ .unwrap_or(model_id)
+ .to_string()
} else {
model_id.to_string()
};
@@ -430,7 +512,7 @@ mod tests {
// `None` cursor_prefs → cursor section degrades to just Auto.
let sections = model_sections_for_inputs(Vec::new(), None);
- assert_eq!(sections.len(), 3);
+ assert_eq!(sections.len(), 4);
assert_eq!(sections[0].id, "claude");
assert_eq!(sections[0].status, AgentModelSectionStatus::Ready);
assert_eq!(
@@ -468,17 +550,40 @@ mod tests {
.iter()
.all(|model| model.supports_fast_mode));
- assert_eq!(sections[2].id, "cursor");
+ assert_eq!(sections[2].id, "copilot");
assert_eq!(sections[2].status, AgentModelSectionStatus::Ready);
- // Without an `app.cursor_provider` row in the test DB, the Cursor
- // section degrades to the hard fallback: a single Auto entry.
- // Helmor id is the namespaced `cursor-default`; cli_model is the
- // bare `default` Cursor's SDK expects.
- let auto = §ions[2].options[0];
+ assert_eq!(
+ sections[2]
+ .options
+ .iter()
+ .map(|model| model.id.as_str())
+ .collect::>(),
+ vec![
+ "copilot-claude-opus-4.7",
+ "copilot-claude-sonnet-4.6",
+ "copilot-claude-sonnet-4.5",
+ "copilot-claude-opus-4.5",
+ "copilot-claude-haiku-4.5",
+ "copilot-gemini-2.5-pro",
+ "copilot-gpt-5.5",
+ "copilot-gpt-5.4",
+ "copilot-gpt-5.4-mini",
+ "copilot-gpt-5.3-codex",
+ "copilot-gpt-5.2-codex",
+ "copilot-gpt-5.2",
+ "copilot-gpt-5-mini",
+ "copilot-gpt-4.1",
+ "copilot-gpt-4o",
+ ]
+ );
+
+ assert_eq!(sections[3].id, "cursor");
+ assert_eq!(sections[3].status, AgentModelSectionStatus::Ready);
+ let auto = §ions[3].options[0];
assert_eq!(auto.id, "cursor-default");
assert_eq!(auto.cli_model, "default");
assert_eq!(auto.provider, "cursor");
- assert_eq!(sections[2].options.len(), 1);
+ assert_eq!(sections[3].options.len(), 1);
}
#[test]
@@ -495,7 +600,7 @@ mod tests {
None,
);
- assert_eq!(sections.len(), 3);
+ assert_eq!(sections.len(), 4);
assert_eq!(sections[0].id, "claude");
assert_eq!(sections[0].label, "Claude Code");
assert_eq!(
diff --git a/src-tauri/src/agents/provider_capabilities.rs b/src-tauri/src/agents/provider_capabilities.rs
new file mode 100644
index 000000000..51784ed7f
--- /dev/null
+++ b/src-tauri/src/agents/provider_capabilities.rs
@@ -0,0 +1,307 @@
+//! Provider capability contract.
+//!
+//! Encodes "does this provider support feature X?" as data instead of
+//! `provider == "codex"` checks scattered across the codebase. The
+//! frontend and the streaming layer both read off the same shape so
+//! adding a new provider (Copilot/ACP via #511, Pi via #321, …) is a
+//! single edit here plus a row in the test matrix below.
+//!
+//! Scope of this slice is intentionally narrow:
+//! - shape + helper only; no new provider, no behavior change for the
+//! three providers Helmor ships today;
+//! - capabilities are the ones that have real call sites today
+//! (active-goal, plan-mode, slash commands, context usage, steer,
+//! display name, permission modes). Adding a new field is cheap;
+//! adding a field with no call site is noise.
+
+use serde::{Deserialize, Serialize};
+
+/// Permission-mode literals the composer's dropdown surfaces. Carried
+/// as `&'static str` so the order is stable and the values match the
+/// SDK strings the sidecars expect on the wire.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum PermissionMode {
+ Default,
+ AcceptEdits,
+ Plan,
+ BypassPermissions,
+}
+
+impl PermissionMode {
+ pub const fn as_str(&self) -> &'static str {
+ match self {
+ Self::Default => "default",
+ Self::AcceptEdits => "acceptEdits",
+ Self::Plan => "plan",
+ Self::BypassPermissions => "bypassPermissions",
+ }
+ }
+}
+
+/// Static capability table for a single provider. Carried in
+/// [`AgentModelSection`] etc. so the frontend can branch on data
+/// rather than on the provider id string. The fields are the union of
+/// the in-tree call sites that previously hard-coded
+/// `provider === ""` checks; new fields land next to the call site
+/// that needs them and ship with a matrix entry covering all known
+/// providers.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ProviderCapabilities {
+ /// Stable provider id — `"claude"`, `"codex"`, `"cursor"`, …. Same
+ /// string the rest of the codebase uses on `AgentModelOption`.
+ pub provider: String,
+ /// Human-readable label used in confirmation dialogs, status copy,
+ /// etc. Lets us rename "Codex" to "OpenAI" (or vice versa) in one
+ /// place without grepping for every string literal.
+ pub display_name: String,
+ /// Provider emits a "current plan" artefact the frontend can pin —
+ /// either Codex `turn/plan/updated` or Claude `ExitPlanMode`. The
+ /// pipeline maps both onto the same projection (see
+ /// `agents::session_plan`).
+ pub supports_plan_mode: bool,
+ /// Provider has a long-running goal/autopilot loop the frontend
+ /// needs to special-case during composer submit + stop (today:
+ /// Codex `/goal …`). Frontend uses this to decide whether to fire
+ /// the `getSessionCodexGoal` query and intercept `/goal` prompts.
+ pub supports_active_goal: bool,
+ /// Provider reports a live context-usage signal we surface in the
+ /// composer ring. Matches the per-model `supports_context_usage`
+ /// flag on [`super::catalog::AgentModelOption`] — duplicated here
+ /// as a provider-level default so frontends without a selected
+ /// model can still light the ring up.
+ pub supports_context_usage: bool,
+ /// Provider supports mid-turn "steer" follow-ups (queueing a new
+ /// prompt before the current one finishes).
+ pub supports_steer: bool,
+ /// Provider has slash-command discovery (`list_slash_commands`
+ /// returns a meaningful list, not an empty stub).
+ pub supports_slash_commands: bool,
+ /// Provider authenticates via an in-app key entry rather than the
+ /// embedded login terminal flow. True for Cursor; false for Claude
+ /// + Codex.
+ pub requires_api_key: bool,
+ /// Permission modes the composer's permission-mode dropdown should
+ /// offer for this provider, in display order. The first entry is
+ /// the default selection for new sessions.
+ pub permission_modes: Vec,
+}
+
+/// Capabilities for the three providers Helmor ships today.
+///
+/// New providers (e.g. Copilot via #511, Pi via #321) land here with a
+/// matrix entry in [`tests::capabilities_table`] documenting every
+/// flag against the Claude reference row — keeps the contract honest.
+pub fn capabilities_for_provider(provider: &str) -> ProviderCapabilities {
+ match provider {
+ "codex" => ProviderCapabilities {
+ provider: "codex".into(),
+ display_name: "Codex".into(),
+ supports_plan_mode: true,
+ supports_active_goal: true,
+ supports_context_usage: true,
+ supports_steer: true,
+ supports_slash_commands: true,
+ requires_api_key: false,
+ permission_modes: vec![PermissionMode::Default, PermissionMode::BypassPermissions],
+ },
+ "cursor" => ProviderCapabilities {
+ provider: "cursor".into(),
+ display_name: "Cursor".into(),
+ supports_plan_mode: false,
+ supports_active_goal: false,
+ supports_context_usage: false,
+ supports_steer: false,
+ supports_slash_commands: true,
+ requires_api_key: true,
+ permission_modes: vec![PermissionMode::Default],
+ },
+ "copilot" => ProviderCapabilities {
+ provider: "copilot".into(),
+ display_name: "Copilot".into(),
+ supports_plan_mode: true,
+ supports_active_goal: false,
+ supports_context_usage: false,
+ supports_steer: false,
+ supports_slash_commands: true,
+ requires_api_key: false,
+ permission_modes: vec![PermissionMode::Default, PermissionMode::BypassPermissions],
+ },
+ // Default arm covers "claude" and anything we haven't onboarded
+ // yet — keeping the safe defaults equal to Claude's behaviour
+ // means an unknown id never accidentally disables the
+ // composer's full feature surface.
+ _ => ProviderCapabilities {
+ provider: "claude".into(),
+ display_name: "Claude".into(),
+ supports_plan_mode: true,
+ supports_active_goal: false,
+ supports_context_usage: true,
+ supports_steer: true,
+ supports_slash_commands: true,
+ requires_api_key: false,
+ permission_modes: vec![
+ PermissionMode::Default,
+ PermissionMode::AcceptEdits,
+ PermissionMode::Plan,
+ PermissionMode::BypassPermissions,
+ ],
+ },
+ }
+}
+
+/// Convenience: list every provider Helmor ships today. Frontends use
+/// this to render the capability table in settings (eventually), and
+/// tests use it to assert there are no holes in the matrix.
+pub const KNOWN_PROVIDERS: &[&str] = &["claude", "codex", "cursor", "copilot"];
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Cross-provider capability matrix. Locks down every flag for
+ /// every shipping provider so the next person adding a feature
+ /// flag is forced to fill in the matrix or break this test.
+ #[test]
+ fn capabilities_table() {
+ for provider in KNOWN_PROVIDERS {
+ let caps = capabilities_for_provider(provider);
+ assert_eq!(
+ caps.provider, *provider,
+ "capabilities for `{provider}` returned a mismatched provider id"
+ );
+ assert!(
+ !caps.display_name.is_empty(),
+ "{provider}: display_name must not be empty"
+ );
+ assert!(
+ caps.permission_modes.contains(&PermissionMode::Default),
+ "{provider}: every provider must support the 'default' permission mode"
+ );
+ }
+ }
+
+ #[test]
+ fn claude_capabilities() {
+ let caps = capabilities_for_provider("claude");
+ assert_eq!(caps.provider, "claude");
+ assert!(caps.supports_plan_mode, "Claude has ExitPlanMode");
+ assert!(
+ !caps.supports_active_goal,
+ "Claude has no long-running goal loop"
+ );
+ assert!(caps.supports_context_usage);
+ assert!(caps.supports_steer);
+ assert!(caps.supports_slash_commands);
+ assert!(!caps.requires_api_key, "Claude uses embedded login");
+ assert_eq!(
+ caps.permission_modes,
+ vec![
+ PermissionMode::Default,
+ PermissionMode::AcceptEdits,
+ PermissionMode::Plan,
+ PermissionMode::BypassPermissions,
+ ]
+ );
+ }
+
+ #[test]
+ fn codex_capabilities() {
+ let caps = capabilities_for_provider("codex");
+ assert_eq!(caps.provider, "codex");
+ assert!(caps.supports_plan_mode, "Codex emits turn/plan/updated");
+ assert!(
+ caps.supports_active_goal,
+ "Codex has /goal — composer must intercept it"
+ );
+ assert!(caps.supports_context_usage);
+ assert!(caps.supports_steer);
+ assert!(caps.supports_slash_commands);
+ assert!(!caps.requires_api_key, "Codex uses embedded login");
+ // Codex doesn't expose `acceptEdits` / `plan` — only the two
+ // bypass-or-default modes its sidecar understands.
+ assert_eq!(
+ caps.permission_modes,
+ vec![PermissionMode::Default, PermissionMode::BypassPermissions]
+ );
+ }
+
+ #[test]
+ fn cursor_capabilities() {
+ let caps = capabilities_for_provider("cursor");
+ assert_eq!(caps.provider, "cursor");
+ assert!(!caps.supports_plan_mode);
+ assert!(!caps.supports_active_goal);
+ assert!(
+ !caps.supports_context_usage,
+ "Cursor doesn't surface context usage today"
+ );
+ assert!(!caps.supports_steer);
+ assert!(caps.supports_slash_commands);
+ assert!(caps.requires_api_key, "Cursor authenticates via API key");
+ assert_eq!(caps.permission_modes, vec![PermissionMode::Default]);
+ }
+
+ #[test]
+ fn copilot_capabilities() {
+ let caps = capabilities_for_provider("copilot");
+ assert_eq!(caps.provider, "copilot");
+ assert!(caps.supports_plan_mode, "Copilot ACP emits plan updates");
+ assert!(!caps.supports_active_goal);
+ assert!(
+ !caps.supports_context_usage,
+ "Copilot ACP doesn't expose token usage yet"
+ );
+ assert!(!caps.supports_steer);
+ assert!(caps.supports_slash_commands);
+ assert!(!caps.requires_api_key, "Copilot uses GitHub auth via CLI");
+ assert_eq!(
+ caps.permission_modes,
+ vec![PermissionMode::Default, PermissionMode::BypassPermissions]
+ );
+ }
+
+ #[test]
+ fn unknown_provider_falls_back_to_claude_defaults() {
+ let caps = capabilities_for_provider("pi");
+ let claude = capabilities_for_provider("claude");
+ assert_eq!(caps.provider, claude.provider);
+ assert_eq!(caps.supports_plan_mode, claude.supports_plan_mode);
+ assert_eq!(caps.permission_modes, claude.permission_modes);
+ }
+
+ /// Wire-format gate: the frontend reads the capability shape
+ /// straight out of `getProviderCapabilities`, so a snake_case
+ /// field leaking past `rename_all = "camelCase"` would silently
+ /// break every consumer.
+ #[test]
+ fn serialization_uses_camel_case_fields() {
+ let caps = capabilities_for_provider("claude");
+ let json = serde_json::to_value(&caps).unwrap();
+ assert!(json.get("displayName").is_some());
+ assert!(json.get("supportsPlanMode").is_some());
+ assert!(json.get("supportsActiveGoal").is_some());
+ assert!(json.get("supportsContextUsage").is_some());
+ assert!(json.get("supportsSteer").is_some());
+ assert!(json.get("supportsSlashCommands").is_some());
+ assert!(json.get("requiresApiKey").is_some());
+ assert!(json.get("permissionModes").is_some());
+ let raw = serde_json::to_string(&caps).unwrap();
+ assert!(!raw.contains('_'), "snake_case field leaked: {raw}");
+ }
+
+ #[test]
+ fn permission_mode_serializes_to_sdk_wire_strings() {
+ for (mode, wire) in [
+ (PermissionMode::Default, "default"),
+ (PermissionMode::AcceptEdits, "acceptEdits"),
+ (PermissionMode::Plan, "plan"),
+ (PermissionMode::BypassPermissions, "bypassPermissions"),
+ ] {
+ assert_eq!(mode.as_str(), wire);
+ let json = serde_json::to_string(&mode).unwrap();
+ assert_eq!(json, format!("\"{wire}\""));
+ }
+ }
+}
diff --git a/src-tauri/src/agents/queries.rs b/src-tauri/src/agents/queries.rs
index 14ffc7538..938ee41f1 100644
--- a/src-tauri/src/agents/queries.rs
+++ b/src-tauri/src/agents/queries.rs
@@ -1136,6 +1136,107 @@ fn parse_cursor_parameters(arr: &[Value]) -> Vec {
.collect()
}
+// ---------------------------------------------------------------------------
+// Copilot model list — proxied to the sidecar's `listModels` for copilot
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CopilotModelEntry {
+ pub id: String,
+ pub label: String,
+ pub effort_levels: Vec,
+}
+
+const LIST_COPILOT_MODELS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
+
+pub fn fetch_copilot_models(
+ sidecar: &crate::sidecar::ManagedSidecar,
+) -> CmdResult> {
+ let request_id = Uuid::new_v4().to_string();
+ let params = serde_json::json!({ "provider": "copilot" });
+ let sidecar_req = crate::sidecar::SidecarRequest {
+ id: request_id.clone(),
+ method: "listModels".to_string(),
+ params,
+ };
+
+ let rx = sidecar.subscribe(&request_id);
+ if let Err(e) = sidecar.send(&sidecar_req) {
+ sidecar.unsubscribe(&request_id);
+ return Err(anyhow::anyhow!("Sidecar send failed: {e}").into());
+ }
+
+ let mut models: Vec = Vec::new();
+ let mut error: Option = None;
+
+ loop {
+ match rx.recv_timeout(LIST_COPILOT_MODELS_TIMEOUT) {
+ Ok(event) => match event.event_type() {
+ "modelsListed" => {
+ if let Some(entries) = event.raw.get("models").and_then(Value::as_array) {
+ for entry in entries {
+ let Some(id) = entry.get("id").and_then(Value::as_str) else {
+ continue;
+ };
+ let label = entry
+ .get("label")
+ .and_then(Value::as_str)
+ .unwrap_or(id)
+ .to_string();
+ let effort_levels = entry
+ .get("effortLevels")
+ .and_then(Value::as_array)
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_str().map(str::to_string))
+ .collect()
+ })
+ .unwrap_or_default();
+ models.push(CopilotModelEntry {
+ id: id.to_string(),
+ label,
+ effort_levels,
+ });
+ }
+ }
+ break;
+ }
+ "error" => {
+ error = Some(
+ event
+ .raw
+ .get("message")
+ .and_then(Value::as_str)
+ .unwrap_or("Unknown error")
+ .to_string(),
+ );
+ break;
+ }
+ _ => {}
+ },
+ Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
+ error = Some(format!(
+ "Copilot model list timed out after {}s",
+ LIST_COPILOT_MODELS_TIMEOUT.as_secs()
+ ));
+ break;
+ }
+ Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
+ error = Some("Sidecar disconnected during Copilot model list".to_string());
+ break;
+ }
+ }
+ }
+
+ sidecar.unsubscribe(&request_id);
+
+ if let Some(message) = error {
+ return Err(anyhow::anyhow!(message).into());
+ }
+ Ok(models)
+}
+
// ---------------------------------------------------------------------------
// Live context-usage (hover popover, Claude only)
// ---------------------------------------------------------------------------
diff --git a/src-tauri/src/commands/system_commands.rs b/src-tauri/src/commands/system_commands.rs
index 32f01d4b3..3b996723c 100644
--- a/src-tauri/src/commands/system_commands.rs
+++ b/src-tauri/src/commands/system_commands.rs
@@ -18,7 +18,7 @@ use super::common::{run_blocking, CmdResult};
// Best-fit fixed window size for the current onboarding motion layout.
// Resizing is restored when onboarding exits.
const ONBOARDING_WINDOW_WIDTH: f64 = 1300.0;
-const ONBOARDING_WINDOW_HEIGHT: f64 = 810.0;
+const ONBOARDING_WINDOW_HEIGHT: f64 = 880.0;
const HELMOR_SKILL_NAME: &str = "helmor-cli";
const HELMOR_SKILL_SOURCE: &str = "dohooo/helmor/.agents/skills/helmor-cli";
@@ -47,6 +47,7 @@ pub struct AgentLoginStatus {
pub claude: bool,
pub codex: bool,
pub cursor: bool,
+ pub copilot: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub codex_provider: Option,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -394,6 +395,7 @@ fn helmor_skills_status() -> anyhow::Result {
claude: claude_login_ready(),
codex: codex_auth_status().ready,
cursor: cursor_login_ready(),
+ copilot: copilot_login_ready(),
codex_provider: None,
codex_auth_method: None,
},
@@ -534,6 +536,7 @@ pub async fn install_helmor_skills() -> CmdResult {
claude: claude_login_ready(),
codex: codex_auth_status().ready,
cursor: cursor_login_ready(),
+ copilot: copilot_login_ready(),
codex_provider: None,
codex_auth_method: None,
};
@@ -665,6 +668,7 @@ pub async fn get_agent_login_status() -> CmdResult {
claude: claude_login_ready(),
codex: codex.ready,
cursor: cursor_login_ready(),
+ copilot: copilot_login_ready(),
codex_provider: codex.provider,
codex_auth_method: codex.auth_method.map(str::to_string),
})
@@ -801,10 +805,174 @@ fn env_var_is_present(key: &str) -> bool {
.unwrap_or(false)
}
+fn copilot_login_ready() -> bool {
+ if let Some(home) = std::env::var_os("HOME") {
+ let path = std::path::Path::new(&home)
+ .join(".copilot")
+ .join("config.json");
+ if let Ok(raw) = std::fs::read_to_string(&path) {
+ let stripped: String = raw
+ .lines()
+ .filter(|line| !line.trim_start().starts_with("//"))
+ .collect::>()
+ .join("\n");
+ if let Ok(parsed) = serde_json::from_str::(&stripped) {
+ if let Some(arr) = parsed
+ .get("loggedInUsers")
+ .and_then(serde_json::Value::as_array)
+ {
+ return !arr.is_empty();
+ }
+ }
+ }
+ }
+ match std::process::Command::new("gh")
+ .args(["auth", "status"])
+ .output()
+ {
+ Ok(gh_output) => gh_output.status.success(),
+ Err(_) => false,
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CopilotAccountInfo {
+ pub login: String,
+ pub copilot_plan: String,
+ pub premium_requests_remaining: u64,
+ pub premium_requests_entitlement: u64,
+ pub premium_requests_percent_remaining: f64,
+ pub chat_unlimited: bool,
+ pub quota_reset_date: Option,
+ pub overage_permitted: bool,
+}
+
+#[tauri::command]
+pub async fn get_copilot_account_info() -> CmdResult
- {/* h-13 (~52px) keeps three tiles + Back/Next inside the
- step container at ~720–820px laptop viewports. */}
- {loginItems.map(
+ {primaryItems.map(
({ icon: Icon, provider, label, description, status }) => {
const subLabel =
provider === "cursor" && cursorKeyError
@@ -149,6 +157,59 @@ export function AgentLoginStep({
)}
+ {moreItems.length > 0 && (
+
+
+
+
+
+
+ {moreItems.map(
+ ({ icon: Icon, provider, label, description, status }) => (
+
+
+
+
+
+
+ {label}
+
+
+ {description}
+
+
+
+
+ ),
+ )}
+
+
+
+ )}
+
;
}
+ if (agentType === "copilot") {
+ return (
+
+ );
+ }
return
;
}
diff --git a/src/features/panel/use-confirm-session-close.tsx b/src/features/panel/use-confirm-session-close.tsx
index 2943d2fa1..193f61d80 100644
--- a/src/features/panel/use-confirm-session-close.tsx
+++ b/src/features/panel/use-confirm-session-close.tsx
@@ -1,10 +1,12 @@
-import type { QueryClient } from "@tanstack/react-query";
+import { type QueryClient, useQuery } from "@tanstack/react-query";
import { type ReactNode, useCallback, useMemo, useState } from "react";
import {
+ findProviderCapabilities,
stopAgentStream,
type WorkspaceDetail,
type WorkspaceSessionSummary,
} from "@/lib/api";
+import { providerCapabilitiesQueryOptions } from "@/lib/query-client";
import type { PushWorkspaceToast } from "@/lib/workspace-toast-context";
import { shouldConfirmRunningSessionClose } from "./close-guard";
import { RunningSessionCloseDialog } from "./running-session-close-dialog";
@@ -105,15 +107,22 @@ export function useConfirmSessionClose({
await performClose(request);
}, [pending, performClose, pushToast]);
+ const capsQuery = useQuery(providerCapabilitiesQueryOptions());
+ const capsTable = capsQuery.data ?? [];
+
const agentLabel = useMemo(() => {
if (!pending) {
return "Claude";
}
- const provider = pending.provider ?? pending.session.agentType;
- if (provider === "codex") return "Codex";
- if (provider === "cursor") return "Cursor";
- return "Claude";
- }, [pending]);
+ const provider = pending.provider ?? pending.session.agentType ?? "";
+ // Data-driven display name — single source of truth in
+ // `agents::provider_capabilities`. Falls back to "Claude" when
+ // the capability table hasn't loaded yet (cold first paint)
+ // or for an unknown provider id (matches the Rust helper's
+ // fallback to Claude defaults).
+ const caps = findProviderCapabilities(capsTable, provider);
+ return caps?.displayName ?? "Claude";
+ }, [pending, capsTable]);
const dialogNode = (
+
)}
diff --git a/src/features/settings/panels/copilot-provider.tsx b/src/features/settings/panels/copilot-provider.tsx
new file mode 100644
index 000000000..062246274
--- /dev/null
+++ b/src/features/settings/panels/copilot-provider.tsx
@@ -0,0 +1,313 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Loader2, LogOut, RefreshCcw } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ type CopilotModelEntry,
+ copilotLogout,
+ getAgentLoginStatus,
+ getCopilotAccountInfo,
+ listCopilotModels,
+ openAgentLoginTerminal,
+} from "@/lib/api";
+import { cn } from "@/lib/utils";
+import { SettingsRow } from "../components/settings-row";
+
+function formatPlan(plan: string): string {
+ switch (plan) {
+ case "individual_pro":
+ return "Copilot Pro";
+ case "individual_pro_plus":
+ return "Copilot Pro+";
+ case "business":
+ return "Copilot Business";
+ case "enterprise":
+ return "Copilot Enterprise";
+ default:
+ return plan;
+ }
+}
+
+function formatResetDate(date: string | null): string {
+ if (!date) return "—";
+ const d = new Date(date);
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
+}
+
+function quotaBarColor(percent: number): string {
+ if (percent < 20) return "bg-red-500";
+ if (percent < 40) return "bg-yellow-500";
+ if (percent < 60) return "bg-lime-500";
+ return "bg-green-500";
+}
+
+export function CopilotProviderPanel() {
+ const queryClient = useQueryClient();
+
+ const statusQuery = useQuery({
+ queryKey: ["agentLoginStatus"],
+ queryFn: getAgentLoginStatus,
+ refetchInterval: 2000,
+ refetchOnWindowFocus: true,
+ });
+
+ const isReady = statusQuery.data?.copilot ?? false;
+
+ const accountQuery = useQuery({
+ queryKey: ["copilotAccountInfo"],
+ queryFn: getCopilotAccountInfo,
+ enabled: isReady,
+ staleTime: 60_000,
+ });
+
+ const [fetchError, setFetchError] = useState
(null);
+ const [cachedModels, setCachedModels] = useState([]);
+
+ const fetchMutation = useMutation({
+ mutationFn: listCopilotModels,
+ onSuccess: (models) => {
+ setFetchError(null);
+ setCachedModels(models);
+ },
+ onError: (error) => {
+ setFetchError(error instanceof Error ? error.message : String(error));
+ },
+ });
+
+ const logoutMutation = useMutation({
+ mutationFn: copilotLogout,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["agentLoginStatus"] });
+ queryClient.invalidateQueries({ queryKey: ["copilotAccountInfo"] });
+ setCachedModels([]);
+ },
+ });
+
+ const [signingIn, setSigningIn] = useState(false);
+ const handleSignIn = useCallback(() => {
+ setSigningIn(true);
+ openAgentLoginTerminal("copilot");
+ }, []);
+
+ useEffect(() => {
+ if (isReady && signingIn) setSigningIn(false);
+ }, [isReady, signingIn]);
+
+ const account = accountQuery.data;
+
+ return (
+
+
+ GitHub Copilot (ACP)
+
+
+ {/* Authentication — token validity + login/logout */}
+
+
+ {statusQuery.isLoading ? (
+
+
+ Checking…
+
+ ) : isReady ? (
+
+ Authenticated
+
+
+ ) : (
+
+
+ {signingIn ? "Waiting for sign-in…" : "Need Reauthentication"}
+
+
+
+ )}
+
+
+
+ {/* Account info card */}
+ {isReady && accountQuery.isLoading && (
+
+
+
+ Loading account info…
+
+
+ )}
+
+ {isReady && accountQuery.isError && (
+
+
+ Could not load account info. Check your GitHub authentication.
+
+
+ )}
+
+ {isReady && account && (
+
+ {/* Row 1: Avatar + Identity + Plan */}
+
+

{
+ e.currentTarget.style.display = "none";
+ }}
+ />
+
+
+ {account.login}
+
+
+ Plan: {formatPlan(account.copilotPlan)}
+
+
+
+
+ {/* Row 2: Premium Requests */}
+ {account.premiumRequestsEntitlement > 0 && (
+
+
+ Premium Requests
+
+ {account.premiumRequestsRemaining} /{" "}
+ {account.premiumRequestsEntitlement} (
+ {Math.round(account.premiumRequestsPercentRemaining)}%) ·
+ Resets {formatResetDate(account.quotaResetDate)}
+
+
+
+
+ )}
+
+ {/* Row 3: Chat */}
+ {account.chatUnlimited && (
+
+
+ Chat
+ Unlimited
+
+
+
+ )}
+
+ )}
+
+ {/* Models */}
+ {isReady && (
+
+
+
+
+ Models
+
+
+ {fetchError
+ ? `Could not fetch — ${fetchError}`
+ : "Available models from Copilot API."}
+
+
+
+
+
+ {cachedModels.length > 0 && (
+
+
+
+
+ | Model |
+
+ Effort Levels
+ |
+
+
+
+ {cachedModels.map((model) => (
+
+ |
+ {model.label}
+ |
+
+ {model.effortLevels.length > 0
+ ? model.effortLevels.join(", ")
+ : "—"}
+ |
+
+ ))}
+
+
+
+ )}
+
+ )}
+
+
+ Requires a GitHub Copilot subscription and the{" "}
+ copilot CLI.
+
+
+ );
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 0e55427ee..d2041e371 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -123,7 +123,7 @@ export type DataInfo = {
archiveRoot: string;
};
-export type AgentProvider = "claude" | "codex" | "cursor";
+export type AgentProvider = "claude" | "codex" | "cursor" | "copilot";
export type AgentModelOption = {
id: string;
@@ -145,6 +145,31 @@ export type AgentModelSection = {
options: AgentModelOption[];
};
+/** Wire strings the sidecars accept for permission mode. The composer's
+ * permission-mode dropdown reads {@link ProviderCapabilities.permissionModes}
+ * to decide which entries to render — every provider supports `default`. */
+export type PermissionModeLiteral =
+ | "default"
+ | "acceptEdits"
+ | "plan"
+ | "bypassPermissions";
+
+/** Static capability table for a single provider. Mirrors the Rust
+ * `agents::provider_capabilities::ProviderCapabilities` shape so a
+ * cross-stack provider check is data-driven instead of a scattered
+ * `provider === "codex"` string compare. */
+export type ProviderCapabilities = {
+ provider: string;
+ displayName: string;
+ supportsPlanMode: boolean;
+ supportsActiveGoal: boolean;
+ supportsContextUsage: boolean;
+ supportsSteer: boolean;
+ supportsSlashCommands: boolean;
+ requiresApiKey: boolean;
+ permissionModes: PermissionModeLiteral[];
+};
+
export type AgentSendRequest = {
provider: AgentProvider;
modelId: string;
@@ -764,12 +789,13 @@ export async function exitOnboardingWindowMode(): Promise {
await invoke("exit_onboarding_window_mode");
}
-export type AgentLoginProvider = "claude" | "codex" | "cursor";
+export type AgentLoginProvider = "claude" | "codex" | "cursor" | "copilot";
export type AgentLoginStatusResult = {
claude: boolean;
codex: boolean;
cursor: boolean;
+ copilot: boolean;
codexProvider?: string | null;
codexAuthMethod?: "login" | "apiKey" | string | null;
};
@@ -932,6 +958,26 @@ export async function loadAgentModelSections(): Promise {
}
}
+/** Static provider-capability table. Backed by the Rust source of truth
+ * in `agents::provider_capabilities`; callers cache the result for the
+ * lifetime of the app and look up rows by `provider`. */
+export async function loadProviderCapabilities(): Promise<
+ ProviderCapabilities[]
+> {
+ return invoke("list_provider_capabilities");
+}
+
+/** Look up a single provider's capabilities from a previously-fetched
+ * table. Returns `null` when the provider id isn't represented — the
+ * composer treats that as "use Claude's safe defaults", matching the
+ * Rust helper's fallback. */
+export function findProviderCapabilities(
+ table: readonly ProviderCapabilities[],
+ provider: string,
+): ProviderCapabilities | null {
+ return table.find((caps) => caps.provider === provider) ?? null;
+}
+
export type CursorModelParameterValue = {
value: string;
displayName?: string;
@@ -966,6 +1012,41 @@ export async function listCursorModels(
}
}
+export type CopilotModelEntry = {
+ id: string;
+ label: string;
+ effortLevels: string[];
+};
+
+export async function listCopilotModels(): Promise {
+ try {
+ return await invoke("list_copilot_models");
+ } catch (error) {
+ throw new Error(
+ describeInvokeError(error, "Unable to list Copilot models."),
+ );
+ }
+}
+
+export type CopilotAccountInfo = {
+ login: string;
+ copilotPlan: string;
+ premiumRequestsRemaining: number;
+ premiumRequestsEntitlement: number;
+ premiumRequestsPercentRemaining: number;
+ chatUnlimited: boolean;
+ quotaResetDate: string | null;
+ overagePermitted: boolean;
+};
+
+export async function getCopilotAccountInfo(): Promise {
+ return invoke("get_copilot_account_info");
+}
+
+export async function copilotLogout(): Promise {
+ await invoke("copilot_logout");
+}
+
// ---------------------------------------------------------------------------
// Inbox (kanban-mode left sidebar)
// ---------------------------------------------------------------------------
diff --git a/src/lib/provider-capabilities.test.ts b/src/lib/provider-capabilities.test.ts
new file mode 100644
index 000000000..0e7128cd7
--- /dev/null
+++ b/src/lib/provider-capabilities.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from "vitest";
+import { findProviderCapabilities, type ProviderCapabilities } from "./api";
+
+const claudeCaps: ProviderCapabilities = {
+ provider: "claude",
+ displayName: "Claude",
+ supportsPlanMode: true,
+ supportsActiveGoal: false,
+ supportsContextUsage: true,
+ supportsSteer: true,
+ supportsSlashCommands: true,
+ requiresApiKey: false,
+ permissionModes: ["default", "acceptEdits", "plan", "bypassPermissions"],
+};
+
+const codexCaps: ProviderCapabilities = {
+ provider: "codex",
+ displayName: "Codex",
+ supportsPlanMode: true,
+ supportsActiveGoal: true,
+ supportsContextUsage: true,
+ supportsSteer: true,
+ supportsSlashCommands: true,
+ requiresApiKey: false,
+ permissionModes: ["default", "bypassPermissions"],
+};
+
+const cursorCaps: ProviderCapabilities = {
+ provider: "cursor",
+ displayName: "Cursor",
+ supportsPlanMode: false,
+ supportsActiveGoal: false,
+ supportsContextUsage: false,
+ supportsSteer: false,
+ supportsSlashCommands: true,
+ requiresApiKey: true,
+ permissionModes: ["default"],
+};
+
+const table: ProviderCapabilities[] = [claudeCaps, codexCaps, cursorCaps];
+
+describe("findProviderCapabilities", () => {
+ it.each([
+ ["claude", claudeCaps],
+ ["codex", codexCaps],
+ ["cursor", cursorCaps],
+ ])("returns the row for %s", (provider, expected) => {
+ expect(findProviderCapabilities(table, provider)).toBe(expected);
+ });
+
+ it("returns null for an unknown provider id", () => {
+ // Forward-compat: callers receiving null are expected to fall
+ // back to safe defaults. This mirrors the Rust helper's
+ // behaviour (Claude defaults) at the data-access boundary.
+ expect(findProviderCapabilities(table, "copilot")).toBeNull();
+ });
+
+ it("returns null on an empty table", () => {
+ expect(findProviderCapabilities([], "claude")).toBeNull();
+ });
+
+ it("distinguishes Codex active-goal support from Claude / Cursor", () => {
+ // Regression gate for the composer's `/goal` interception
+ // switching from `provider === "codex"` to a capability check.
+ // If a future provider ever needs `supportsActiveGoal`, the
+ // composer's special-case path needs to be reviewed alongside.
+ expect(findProviderCapabilities(table, "codex")?.supportsActiveGoal).toBe(
+ true,
+ );
+ expect(findProviderCapabilities(table, "claude")?.supportsActiveGoal).toBe(
+ false,
+ );
+ expect(findProviderCapabilities(table, "cursor")?.supportsActiveGoal).toBe(
+ false,
+ );
+ });
+
+ it("surfaces Cursor's requires-api-key flag", () => {
+ // Regression gate: a future refactor of the onboarding/login
+ // step would lose the in-app API-key path if this flag flipped
+ // silently. Keep the assertion explicit per-provider.
+ expect(findProviderCapabilities(table, "cursor")?.requiresApiKey).toBe(
+ true,
+ );
+ expect(findProviderCapabilities(table, "claude")?.requiresApiKey).toBe(
+ false,
+ );
+ expect(findProviderCapabilities(table, "codex")?.requiresApiKey).toBe(
+ false,
+ );
+ });
+});
diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts
index c622515e5..3fe63f760 100644
--- a/src/lib/query-client.ts
+++ b/src/lib/query-client.ts
@@ -35,6 +35,7 @@ import {
loadArchivedWorkspaces,
loadAutoCloseActionKinds,
loadAutoCloseOptInAsked,
+ loadProviderCapabilities,
loadSessionThreadMessages,
loadWorkspaceDetail,
loadWorkspaceForgeActionStatus,
@@ -63,6 +64,7 @@ export const helmorQueryKeys = {
archivedWorkspaces: ["archivedWorkspaces"] as const,
repositories: ["repositories"] as const,
agentModelSections: ["agentModelSections"] as const,
+ providerCapabilities: ["providerCapabilities"] as const,
workspaceDetail: (workspaceId: string) =>
["workspaceDetail", workspaceId] as const,
workspaceSessions: (workspaceId: string) =>
@@ -411,6 +413,23 @@ export function agentModelSectionsQueryOptions() {
});
}
+/** Provider-capability table. The shape is intentionally static across
+ * the app's lifetime (no per-session inputs), so the query is cached
+ * forever and persisted to disk like the model catalog — first paint
+ * on cold start has the data ready. Cleared via the React Query
+ * devtools or a release-bumped persistence key if the shape changes. */
+export function providerCapabilitiesQueryOptions() {
+ return queryOptions({
+ queryKey: helmorQueryKeys.providerCapabilities,
+ queryFn: loadProviderCapabilities,
+ staleTime: Number.POSITIVE_INFINITY,
+ gcTime: Number.POSITIVE_INFINITY,
+ refetchOnWindowFocus: false,
+ retry: false,
+ meta: PERSIST_META,
+ });
+}
+
export function workspaceDetailQueryOptions(workspaceId: string) {
return queryOptions({
queryKey: helmorQueryKeys.workspaceDetail(workspaceId),
diff --git a/src/lib/workspace-helpers.ts b/src/lib/workspace-helpers.ts
index 50e1a4580..fa99253b3 100644
--- a/src/lib/workspace-helpers.ts
+++ b/src/lib/workspace-helpers.ts
@@ -692,6 +692,9 @@ export function resolveSessionDisplayProvider({
if (session.agentType === "cursor") {
return "cursor";
}
+ if (session.agentType === "copilot") {
+ return "copilot";
+ }
return null;
}