) => void;
@@ -405,14 +460,13 @@ export function ProviderInstanceCard({
const statusKey: ProviderStatusKey =
(liveProvider?.status as ProviderStatusKey | undefined) ?? (enabled ? "warning" : "disabled");
const statusStyle = PROVIDER_STATUS_STYLES[statusKey];
- const rawSummary = getProviderSummary(liveProvider);
+ const summary = getProviderSummary(liveProvider);
const authEmail = liveProvider?.auth.email;
- const hasAuthenticatedEmail =
- liveProvider?.auth.status === "authenticated" && Boolean(authEmail?.trim());
- const authenticatedDetail = hasAuthenticatedEmail
- ? (liveProvider?.auth.label ?? liveProvider?.auth.type ?? null)
- : null;
- const summary = rawSummary;
+ const authLabel = liveProvider?.auth.label;
+ const authType = liveProvider?.auth.type;
+ const isAuthenticated = liveProvider?.auth.status === "authenticated";
+ const hasAuthenticatedLabel = Boolean(authLabel?.trim() || authType?.trim());
+ const hasAuthenticatedEmail = isAuthenticated && Boolean(authEmail?.trim());
const versionLabel = getProviderVersionLabel(liveProvider?.version);
const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);
const updateCommand = versionAdvisory?.updateCommand ?? null;
@@ -583,11 +637,13 @@ export function ProviderInstanceCard({
const authRowNode = (
- {hasAuthenticatedEmail ? (
+ {isAuthenticated ? (
<>
- Authenticated as
+
+ {hasAuthenticatedEmail || hasAuthenticatedLabel ? "Authenticated as" : "Authenticated"}
+
- {authenticatedDetail ? · {authenticatedDetail} : null}
+
>
) : (
<>
diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts
index bfee6a8d680..6a672785acb 100644
--- a/apps/web/src/components/settings/providerDriverMeta.ts
+++ b/apps/web/src/components/settings/providerDriverMeta.ts
@@ -1,13 +1,22 @@
import {
ClaudeSettings,
CodexSettings,
+ CopilotSettings,
CursorSettings,
GrokSettings,
OpenCodeSettings,
ProviderDriverKind,
} from "@t3tools/contracts";
import type * as Schema from "effect/Schema";
-import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons";
+import {
+ ClaudeAI,
+ CursorIcon,
+ GithubCopilotIcon,
+ GrokIcon,
+ type Icon,
+ OpenAI,
+ OpenCodeIcon,
+} from "../Icons";
type ProviderSettingsSchema = {
readonly fields: Readonly>;
@@ -41,6 +50,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] =
icon: OpenAI,
settingsSchema: CodexSettings,
},
+ {
+ value: ProviderDriverKind.make("copilot"),
+ label: "GitHub Copilot",
+ icon: GithubCopilotIcon,
+ settingsSchema: CopilotSettings,
+ },
{
value: ProviderDriverKind.make("claudeAgent"),
label: "Claude",
diff --git a/apps/web/src/lib/contextWindow.test.ts b/apps/web/src/lib/contextWindow.test.ts
index c3226884a31..3be3a3255a6 100644
--- a/apps/web/src/lib/contextWindow.test.ts
+++ b/apps/web/src/lib/contextWindow.test.ts
@@ -61,6 +61,62 @@ describe("contextWindow", () => {
});
});
+ it("keeps showing the latest reliable context window percentage over newer token-only usage", () => {
+ const snapshot = deriveLatestContextWindowSnapshot([
+ makeActivity("activity-1", "context-window.updated", {
+ usedTokens: 81_659,
+ maxTokens: 258_400,
+ }),
+ makeActivity("activity-2", "context-window.updated", {
+ usedTokens: 126,
+ inputTokens: 120,
+ outputTokens: 6,
+ }),
+ ]);
+
+ expect(snapshot).toMatchObject({
+ usedTokens: 81_659,
+ maxTokens: 258_400,
+ });
+ expect(snapshot?.usedPercentage).toBeCloseTo(31.6, 1);
+ });
+
+ it("falls back to the latest token-only usage when no window limit is available", () => {
+ const snapshot = deriveLatestContextWindowSnapshot([
+ makeActivity("activity-1", "context-window.updated", {
+ usedTokens: 1000,
+ }),
+ makeActivity("activity-2", "context-window.updated", {
+ usedTokens: 1200,
+ }),
+ ]);
+
+ expect(snapshot).toMatchObject({
+ usedTokens: 1200,
+ maxTokens: null,
+ usedPercentage: null,
+ });
+ });
+
+ it("does not reuse a pre-compaction window limit", () => {
+ const snapshot = deriveLatestContextWindowSnapshot([
+ makeActivity("activity-1", "context-window.updated", {
+ usedTokens: 81_659,
+ maxTokens: 258_400,
+ }),
+ makeActivity("activity-2", "context-compaction", {}),
+ makeActivity("activity-3", "context-window.updated", {
+ usedTokens: 126,
+ }),
+ ]);
+
+ expect(snapshot).toMatchObject({
+ usedTokens: 126,
+ maxTokens: null,
+ usedPercentage: null,
+ });
+ });
+
it("formats compact token counts", () => {
expect(formatContextWindowTokens(999)).toBe("999");
expect(formatContextWindowTokens(1400)).toBe("1.4k");
diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts
index 80f7d31cf2f..43c98faa7d0 100644
--- a/apps/web/src/lib/contextWindow.ts
+++ b/apps/web/src/lib/contextWindow.ts
@@ -34,8 +34,12 @@ export function formatProviderDisplayName(provider: string | null | undefined):
return "Claude";
case "codex":
return "Codex";
+ case "copilot":
+ return "GitHub Copilot";
case "cursor":
return "Cursor";
+ case "grok":
+ return "Grok";
case "opencode":
return "OpenCode";
default: {
@@ -47,52 +51,76 @@ export function formatProviderDisplayName(provider: string | null | undefined):
}
}
+function snapshotFromActivity(activity: OrchestrationThreadActivity): ContextWindowSnapshot | null {
+ const payload = asRecord(activity.payload);
+ const usedTokens = asFiniteNumber(payload?.usedTokens);
+ if (usedTokens === null || usedTokens < 0) {
+ return null;
+ }
+
+ const maxTokens = asFiniteNumber(payload?.maxTokens);
+ const usedPercentage =
+ maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null;
+ const remainingTokens =
+ maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null;
+ const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null;
+
+ return {
+ usedTokens,
+ totalProcessedTokens: asFiniteNumber(payload?.totalProcessedTokens),
+ maxTokens,
+ remainingTokens,
+ usedPercentage,
+ remainingPercentage,
+ inputTokens: asFiniteNumber(payload?.inputTokens),
+ cachedInputTokens: asFiniteNumber(payload?.cachedInputTokens),
+ outputTokens: asFiniteNumber(payload?.outputTokens),
+ reasoningOutputTokens: asFiniteNumber(payload?.reasoningOutputTokens),
+ lastUsedTokens: asFiniteNumber(payload?.lastUsedTokens),
+ lastInputTokens: asFiniteNumber(payload?.lastInputTokens),
+ lastCachedInputTokens: asFiniteNumber(payload?.lastCachedInputTokens),
+ lastOutputTokens: asFiniteNumber(payload?.lastOutputTokens),
+ lastReasoningOutputTokens: asFiniteNumber(payload?.lastReasoningOutputTokens),
+ toolUses: asFiniteNumber(payload?.toolUses),
+ durationMs: asFiniteNumber(payload?.durationMs),
+ compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false,
+ updatedAt: activity.createdAt,
+ };
+}
+
export function deriveLatestContextWindowSnapshot(
activities: ReadonlyArray,
): ContextWindowSnapshot | null {
+ let latestTokenOnlySnapshot: ContextWindowSnapshot | null = null;
+
for (let index = activities.length - 1; index >= 0; index -= 1) {
const activity = activities[index];
- if (!activity || activity.kind !== "context-window.updated") {
+ if (!activity) {
continue;
}
- const payload = asRecord(activity.payload);
- const usedTokens = asFiniteNumber(payload?.usedTokens);
- if (usedTokens === null || usedTokens < 0) {
+ if (activity.kind === "context-compaction") {
+ break;
+ }
+
+ if (activity.kind !== "context-window.updated") {
+ continue;
+ }
+
+ const snapshot = snapshotFromActivity(activity);
+ if (!snapshot) {
continue;
}
- const maxTokens = asFiniteNumber(payload?.maxTokens);
- const usedPercentage =
- maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null;
- const remainingTokens =
- maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null;
- const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null;
-
- return {
- usedTokens,
- totalProcessedTokens: asFiniteNumber(payload?.totalProcessedTokens),
- maxTokens,
- remainingTokens,
- usedPercentage,
- remainingPercentage,
- inputTokens: asFiniteNumber(payload?.inputTokens),
- cachedInputTokens: asFiniteNumber(payload?.cachedInputTokens),
- outputTokens: asFiniteNumber(payload?.outputTokens),
- reasoningOutputTokens: asFiniteNumber(payload?.reasoningOutputTokens),
- lastUsedTokens: asFiniteNumber(payload?.lastUsedTokens),
- lastInputTokens: asFiniteNumber(payload?.lastInputTokens),
- lastCachedInputTokens: asFiniteNumber(payload?.lastCachedInputTokens),
- lastOutputTokens: asFiniteNumber(payload?.lastOutputTokens),
- lastReasoningOutputTokens: asFiniteNumber(payload?.lastReasoningOutputTokens),
- toolUses: asFiniteNumber(payload?.toolUses),
- durationMs: asFiniteNumber(payload?.durationMs),
- compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false,
- updatedAt: activity.createdAt,
- };
+ const maxTokens = snapshot.maxTokens ?? null;
+ if (maxTokens !== null && maxTokens > 0) {
+ return snapshot;
+ }
+
+ latestTokenOnlySnapshot ??= snapshot;
}
- return null;
+ return latestTokenOnlySnapshot;
}
export function formatContextWindowTokens(value: number | null): string {
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts
index beb40aadff9..0d8e830524b 100644
--- a/apps/web/src/session-logic.test.ts
+++ b/apps/web/src/session-logic.test.ts
@@ -1119,6 +1119,38 @@ describe("deriveWorkLogEntries", () => {
});
});
+ it("uses Copilot command metadata instead of command output detail", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "copilot-command-complete",
+ kind: "tool.completed",
+ summary: "Ran command",
+ payload: {
+ itemType: "command_execution",
+ title: "Ran command",
+ status: "completed",
+ detail: " M apps/server/src/provider/Layers/CopilotAdapter.ts",
+ data: {
+ toolCallId: "tool-command",
+ toolName: "bash",
+ command: "git status --short",
+ result: {
+ content: " M apps/server/src/provider/Layers/CopilotAdapter.ts",
+ },
+ },
+ },
+ }),
+ ];
+
+ const [entry] = deriveWorkLogEntries(activities);
+ expect(entry).toMatchObject({
+ command: "git status --short",
+ detail: "M apps/server/src/provider/Layers/CopilotAdapter.ts",
+ itemType: "command_execution",
+ toolTitle: "Ran command",
+ });
+ });
+
it("extracts changed file paths for file-change tool activities", () => {
const activities: OrchestrationThreadActivity[] = [
makeActivity({
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index 5576ebeffc1..c9dc39594fa 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -32,6 +32,7 @@ export const PROVIDER_OPTIONS: Array<{
pickerSidebarBadge?: "new" | "soon";
}> = [
{ value: ProviderDriverKind.make("codex"), label: "Codex", available: true },
+ { value: ProviderDriverKind.make("copilot"), label: "GitHub Copilot", available: true },
{ value: ProviderDriverKind.make("claudeAgent"), label: "Claude", available: true },
{
value: ProviderDriverKind.make("opencode"),
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
index 9a9b05f92f2..6878a11f047 100644
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -1601,9 +1601,15 @@ function applyEnvironmentOrchestrationEvent(
// diff summary, but don't settle a turn its session is still running.
const turnStillRunning =
thread.session?.orchestrationStatus === "running" &&
+ thread.session.activeTurnId !== null &&
thread.session.activeTurnId === event.payload.turnId;
+ const anotherTurnStillRunning =
+ thread.session?.orchestrationStatus === "running" &&
+ thread.session.activeTurnId !== null &&
+ thread.session.activeTurnId !== event.payload.turnId;
const latestTurn =
!turnStillRunning &&
+ !anotherTurnStillRunning &&
(thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId)
? buildLatestTurn({
previous: thread.latestTurn,
diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts
index 7788eaace48..ac49748b08d 100644
--- a/packages/contracts/src/model.ts
+++ b/packages/contracts/src/model.ts
@@ -128,6 +128,7 @@ export const ModelCapabilities = Schema.Struct({
export type ModelCapabilities = typeof ModelCapabilities.Type;
const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex");
+const COPILOT_DRIVER_KIND = ProviderDriverKind.make("copilot");
const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
@@ -138,6 +139,7 @@ export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini";
export const DEFAULT_MODEL_BY_PROVIDER: Partial> = {
[CODEX_DRIVER_KIND]: DEFAULT_MODEL,
+ [COPILOT_DRIVER_KIND]: "gpt-4.1",
[CLAUDE_DRIVER_KIND]: "claude-sonnet-4-6",
[CURSOR_DRIVER_KIND]: "auto",
[GROK_DRIVER_KIND]: "grok-build",
@@ -149,6 +151,7 @@ export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Partial<
Record
> = {
[CODEX_DRIVER_KIND]: DEFAULT_GIT_TEXT_GENERATION_MODEL,
+ [COPILOT_DRIVER_KIND]: "gpt-4.1",
[CLAUDE_DRIVER_KIND]: "claude-haiku-4-5",
[CURSOR_DRIVER_KIND]: "composer-2",
[OPENCODE_DRIVER_KIND]: "openai/gpt-5",
@@ -165,6 +168,9 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial<
"5.3-spark": "gpt-5.3-codex-spark",
"gpt-5.3-spark": "gpt-5.3-codex-spark",
},
+ [COPILOT_DRIVER_KIND]: {
+ "4.1": "gpt-4.1",
+ },
[CLAUDE_DRIVER_KIND]: {
opus: "claude-opus-4-8",
"opus-4.8": "claude-opus-4-8",
@@ -201,6 +207,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial<
export const PROVIDER_DISPLAY_NAMES: Partial> = {
[CODEX_DRIVER_KIND]: "Codex",
+ [COPILOT_DRIVER_KIND]: "GitHub Copilot",
[CLAUDE_DRIVER_KIND]: "Claude",
[CURSOR_DRIVER_KIND]: "Cursor",
[GROK_DRIVER_KIND]: "Grok",
diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts
index eb2563eff00..b2df56b9451 100644
--- a/packages/contracts/src/providerRuntime.ts
+++ b/packages/contracts/src/providerRuntime.ts
@@ -22,6 +22,7 @@ const RuntimeEventRawSource = Schema.Union([
Schema.Literal("codex.app-server.notification"),
Schema.Literal("codex.app-server.request"),
Schema.Literal("codex.eventmsg"),
+ Schema.Literal("copilot.sdk.event"),
Schema.Literal("claude.sdk.message"),
Schema.Literal("claude.sdk.permission"),
Schema.Literal("codex.sdk.thread-event"),
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 33781f56c94..d3b22754ded 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -202,6 +202,46 @@ export const CodexSettings = makeProviderSettingsSchema(
);
export type CodexSettings = typeof CodexSettings.Type;
+export const CopilotSettings = makeProviderSettingsSchema(
+ {
+ enabled: Schema.Boolean.pipe(
+ Schema.withDecodingDefault(Effect.succeed(true)),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ binaryPath: TrimmedString.pipe(
+ Schema.withDecodingDefault(Effect.succeed("")),
+ Schema.annotateKey({
+ title: "Binary path",
+ description:
+ "Optional path to a GitHub Copilot CLI binary. Leave blank to use the SDK-bundled CLI.",
+ providerSettingsForm: {
+ placeholder: "Bundled Copilot CLI",
+ clearWhenEmpty: "omit",
+ },
+ }),
+ ),
+ serverUrl: TrimmedString.pipe(
+ Schema.withDecodingDefault(Effect.succeed("")),
+ Schema.annotateKey({
+ title: "Server URL",
+ description: "Leave blank to let T3 Code start the SDK-bundled Copilot server.",
+ providerSettingsForm: {
+ placeholder: "http://127.0.0.1:4141",
+ clearWhenEmpty: "omit",
+ },
+ }),
+ ),
+ customModels: Schema.Array(Schema.String).pipe(
+ Schema.withDecodingDefault(Effect.succeed([])),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ },
+ {
+ order: ["binaryPath", "serverUrl"],
+ },
+);
+export type CopilotSettings = typeof CopilotSettings.Type;
+
export const ClaudeSettings = makeProviderSettingsSchema(
{
enabled: Schema.Boolean.pipe(
@@ -391,6 +431,7 @@ export const ServerSettings = Schema.Struct({
// is removed entirely.
providers: Schema.Struct({
codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
+ copilot: CopilotSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
@@ -447,6 +488,13 @@ const CodexSettingsPatch = Schema.Struct({
customModels: Schema.optionalKey(Schema.Array(Schema.String)),
});
+const CopilotSettingsPatch = Schema.Struct({
+ enabled: Schema.optionalKey(Schema.Boolean),
+ binaryPath: Schema.optionalKey(Schema.String),
+ serverUrl: Schema.optionalKey(Schema.String),
+ customModels: Schema.optionalKey(Schema.Array(Schema.String)),
+});
+
const ClaudeSettingsPatch = Schema.Struct({
enabled: Schema.optionalKey(Schema.Boolean),
binaryPath: Schema.optionalKey(TrimmedString),
@@ -492,6 +540,7 @@ export const ServerSettingsPatch = Schema.Struct({
providers: Schema.optionalKey(
Schema.Struct({
codex: Schema.optionalKey(CodexSettingsPatch),
+ copilot: Schema.optionalKey(CopilotSettingsPatch),
claudeAgent: Schema.optionalKey(ClaudeSettingsPatch),
cursor: Schema.optionalKey(CursorSettingsPatch),
grok: Schema.optionalKey(GrokSettingsPatch),
diff --git a/packages/shared/package.json b/packages/shared/package.json
index ad3db08eb2e..7ae243d234a 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -67,6 +67,10 @@
"types": "./src/toolActivity.ts",
"import": "./src/toolActivity.ts"
},
+ "./providerToolClassification": {
+ "types": "./src/providerToolClassification.ts",
+ "import": "./src/providerToolClassification.ts"
+ },
"./Struct": {
"types": "./src/Struct.ts",
"import": "./src/Struct.ts"
diff --git a/packages/shared/src/providerToolClassification.test.ts b/packages/shared/src/providerToolClassification.test.ts
new file mode 100644
index 00000000000..05bf10fd1e4
--- /dev/null
+++ b/packages/shared/src/providerToolClassification.test.ts
@@ -0,0 +1,46 @@
+import assert from "node:assert/strict";
+
+import { describe, it } from "@effect/vitest";
+
+import {
+ classifyProviderToolItemType,
+ classifyProviderToolRequestType,
+} from "./providerToolClassification.ts";
+
+describe("providerToolClassification", () => {
+ it("classifies common provider tools consistently", () => {
+ assert.equal(classifyProviderToolItemType({ toolName: "bash" }), "command_execution");
+ assert.equal(
+ classifyProviderToolItemType({ toolName: "Task_complete" }),
+ "collab_agent_tool_call",
+ );
+ assert.equal(
+ classifyProviderToolItemType({
+ toolName: "update",
+ arguments: {
+ path: "README.md",
+ content: "new content",
+ },
+ }),
+ "file_change",
+ );
+ assert.equal(classifyProviderToolItemType({ toolName: "Read" }), "dynamic_tool_call");
+ assert.equal(classifyProviderToolItemType({ toolName: "web_fetch" }), "web_search");
+ assert.equal(classifyProviderToolItemType({ toolName: "screenshot" }), "image_view");
+ assert.equal(
+ classifyProviderToolItemType({ toolName: "call_tool", mcpServerName: "github" }),
+ "mcp_tool_call",
+ );
+ });
+
+ it("classifies approval requests from the same rules", () => {
+ assert.equal(classifyProviderToolRequestType("Read"), "file_read_approval");
+ assert.equal(classifyProviderToolRequestType("read_file"), "file_read_approval");
+ assert.equal(classifyProviderToolRequestType("ReadFile"), "file_read_approval");
+ assert.equal(classifyProviderToolRequestType("bash"), "command_execution_approval");
+ assert.equal(classifyProviderToolRequestType("edit_file"), "file_change_approval");
+ assert.equal(classifyProviderToolRequestType("websearch"), "dynamic_tool_call");
+ assert.equal(classifyProviderToolRequestType("web_search"), "dynamic_tool_call");
+ assert.equal(classifyProviderToolRequestType("Task"), "dynamic_tool_call");
+ });
+});
diff --git a/packages/shared/src/providerToolClassification.ts b/packages/shared/src/providerToolClassification.ts
new file mode 100644
index 00000000000..b72de311735
--- /dev/null
+++ b/packages/shared/src/providerToolClassification.ts
@@ -0,0 +1,141 @@
+import type { CanonicalRequestType, ToolLifecycleItemType } from "@t3tools/contracts";
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function trimmedString(value: unknown): string | undefined {
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
+}
+
+function toolArgumentsLookLikeFileChange(arguments_: unknown): boolean {
+ if (!isRecord(arguments_)) {
+ return false;
+ }
+
+ const filePathKeys = [
+ "path",
+ "filePath",
+ "file_path",
+ "file",
+ "fileName",
+ "filename",
+ "targetFile",
+ "target_file",
+ ];
+ const editPayloadKeys = [
+ "content",
+ "newContent",
+ "new_content",
+ "oldString",
+ "old_string",
+ "newString",
+ "new_string",
+ "diff",
+ "patch",
+ "edits",
+ ];
+
+ const hasFilePath = filePathKeys.some((key) => trimmedString(arguments_[key]) !== undefined);
+ const hasEditPayload = editPayloadKeys.some((key) => arguments_[key] !== undefined);
+ return hasFilePath && hasEditPayload;
+}
+
+function toolNameImpliesFileChange(toolName: string, arguments_: unknown): boolean {
+ const normalized = toolName.toLowerCase();
+ if (
+ normalized.includes("write") ||
+ normalized.includes("edit") ||
+ normalized.includes("patch") ||
+ normalized.includes("replace")
+ ) {
+ return true;
+ }
+
+ if (
+ normalized.includes("create") ||
+ normalized.includes("delete") ||
+ normalized.includes("remove") ||
+ normalized.includes("modify") ||
+ normalized.includes("update") ||
+ normalized.includes("insert")
+ ) {
+ return normalized.includes("file") || toolArgumentsLookLikeFileChange(arguments_);
+ }
+
+ return toolArgumentsLookLikeFileChange(arguments_);
+}
+
+export function isReadOnlyProviderToolName(toolName: string): boolean {
+ const normalized = toolName.toLowerCase();
+ return (
+ normalized === "read" ||
+ normalized.includes("read file") ||
+ normalized.includes("read_file") ||
+ normalized.includes("readfile") ||
+ normalized.includes("view") ||
+ normalized.includes("grep") ||
+ normalized.includes("glob") ||
+ normalized.includes("search")
+ );
+}
+
+export function classifyProviderToolItemType(input: {
+ readonly toolName: string;
+ readonly mcpServerName?: string | undefined;
+ readonly arguments?: unknown;
+}): ToolLifecycleItemType {
+ const normalized = input.toolName.toLowerCase();
+ if (input.mcpServerName || normalized.includes("mcp")) {
+ return "mcp_tool_call";
+ }
+ if (
+ normalized === "task" ||
+ normalized === "agent" ||
+ normalized.includes("subagent") ||
+ normalized.includes("sub-agent") ||
+ normalized.includes("agent") ||
+ normalized.includes("delegate") ||
+ normalized.includes("task")
+ ) {
+ return "collab_agent_tool_call";
+ }
+ if (
+ normalized.includes("bash") ||
+ normalized.includes("shell") ||
+ normalized.includes("exec") ||
+ normalized.includes("command") ||
+ normalized.includes("terminal")
+ ) {
+ return "command_execution";
+ }
+ if (toolNameImpliesFileChange(input.toolName, input.arguments)) {
+ return "file_change";
+ }
+ if (
+ normalized.includes("websearch") ||
+ normalized.includes("web search") ||
+ normalized.includes("web_search") ||
+ normalized.includes("web") ||
+ normalized.includes("fetch")
+ ) {
+ return "web_search";
+ }
+ if (normalized.includes("image") || normalized.includes("screenshot")) {
+ return "image_view";
+ }
+ return "dynamic_tool_call";
+}
+
+export function classifyProviderToolRequestType(toolName: string): CanonicalRequestType {
+ const itemType = classifyProviderToolItemType({ toolName });
+ return itemType === "command_execution"
+ ? "command_execution_approval"
+ : itemType === "file_change"
+ ? "file_change_approval"
+ : itemType === "web_search"
+ ? "dynamic_tool_call"
+ : isReadOnlyProviderToolName(toolName)
+ ? "file_read_approval"
+ : "dynamic_tool_call";
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 501422ec25d..668eb39b0b5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -395,6 +395,12 @@ importers:
'@effect/sql-sqlite-bun':
specifier: 4.0.0-beta.78
version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))
+ '@github/copilot':
+ specifier: 1.0.60
+ version: 1.0.60
+ '@github/copilot-sdk':
+ specifier: 1.0.0
+ version: 1.0.0
'@opencode-ai/sdk':
specifier: ^1.3.15
version: 1.15.13
@@ -2401,6 +2407,62 @@ packages:
'@formkit/auto-animate@0.9.0':
resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==}
+ '@github/copilot-darwin-arm64@1.0.60':
+ resolution: {integrity: sha512-TErNaVxsv+uB3bdHwdoKorCd1rhiRh7HkX48vnS7jwqa8EtGgAkzNrHKC7mruL2rnYOOsNIdPfhzQk+2Y6PSxQ==}
+ cpu: [arm64]
+ os: [darwin]
+ hasBin: true
+
+ '@github/copilot-darwin-x64@1.0.60':
+ resolution: {integrity: sha512-PthhcR6PqbQlT04xQKTElpPSJOrJd65nK/l9Sjmpwtk21RrDKs13DCY/19ubP17updYUWBxp3VNfyfN3DAQKOA==}
+ cpu: [x64]
+ os: [darwin]
+ hasBin: true
+
+ '@github/copilot-linux-arm64@1.0.60':
+ resolution: {integrity: sha512-AVahkDVQTiGmHvDjlb4CHO8CFEGqmCEipxi0qTA60oH3Y3W2C4aYBwEBtP/85pN3wUUKZJVrWTCcxdufUBuK2Q==}
+ cpu: [arm64]
+ os: [linux]
+ hasBin: true
+
+ '@github/copilot-linux-x64@1.0.60':
+ resolution: {integrity: sha512-NwQjV2ZyUdJVAO4t7wiT+eR3uNWYP57xaLUIhf6JTMGpsTyN+mAFXW63xpwM/K+Pug62uRDQDBjEeOQRB7qZrA==}
+ cpu: [x64]
+ os: [linux]
+ hasBin: true
+
+ '@github/copilot-linuxmusl-arm64@1.0.60':
+ resolution: {integrity: sha512-AYGPc9vq2k248bVwUbiVJ65kIYYMQQ7ci+S3oefWBIyYtYwAH0n+Q/IGAj49IPrelBarYABAsX+EQZJJC8rhxw==}
+ cpu: [arm64]
+ os: [linux]
+ hasBin: true
+
+ '@github/copilot-linuxmusl-x64@1.0.60':
+ resolution: {integrity: sha512-9/F7yl0/9FpGvYR/TCQtbhu0vIaUVem6U7em85QYaEjkS45nK500pByCMWY0bXv2eSS8U2g+8FOAjfkyLlxwPw==}
+ cpu: [x64]
+ os: [linux]
+ hasBin: true
+
+ '@github/copilot-sdk@1.0.0':
+ resolution: {integrity: sha512-OKjmJMDM+GB2uHr8UA6O0FNs1Gfw/tkoE5vUNlYmKbydc9Yjf6pvuBdseGjAVvzc6f9HIbB5eZKLUrxbOTw+yA==}
+ engines: {node: '>=20.0.0'}
+
+ '@github/copilot-win32-arm64@1.0.60':
+ resolution: {integrity: sha512-ZxxS+Ua1+7Puz80yTOpQ4WS+s32NjrxIsqo8gE0FpuZId16BGOGbWkzWQvR/k2AVBCqpLZ7SK3LfDVKuKJRbpA==}
+ cpu: [arm64]
+ os: [win32]
+ hasBin: true
+
+ '@github/copilot-win32-x64@1.0.60':
+ resolution: {integrity: sha512-e91ZlFz9J1lkadExLg36oN8Ms/xIa03vAEir3DmyCeYebZ+Y48vdS+BwhQEma+GLoxJUOhzHndCckGnMRfNIbA==}
+ cpu: [x64]
+ os: [win32]
+ hasBin: true
+
+ '@github/copilot@1.0.60':
+ resolution: {integrity: sha512-+GjW+GJNo55nwJwt48o9szWcyhuY0u682cBKQI1ay9jVBX8DCCXC6HB6Tyv5/MaM4N7CxTiEgp48aVMkye8K+g==}
+ hasBin: true
+
'@hono/node-server@1.19.14':
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
engines: {node: '>=18.14.1'}
@@ -9507,6 +9569,10 @@ packages:
resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==}
engines: {node: '>=14.0.0'}
+ vscode-jsonrpc@8.2.1:
+ resolution: {integrity: sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==}
+ engines: {node: '>=14.0.0'}
+
vscode-languageserver-protocol@3.17.5:
resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==}
@@ -11706,6 +11772,49 @@ snapshots:
'@formkit/auto-animate@0.9.0': {}
+ '@github/copilot-darwin-arm64@1.0.60':
+ optional: true
+
+ '@github/copilot-darwin-x64@1.0.60':
+ optional: true
+
+ '@github/copilot-linux-arm64@1.0.60':
+ optional: true
+
+ '@github/copilot-linux-x64@1.0.60':
+ optional: true
+
+ '@github/copilot-linuxmusl-arm64@1.0.60':
+ optional: true
+
+ '@github/copilot-linuxmusl-x64@1.0.60':
+ optional: true
+
+ '@github/copilot-sdk@1.0.0':
+ dependencies:
+ '@github/copilot': 1.0.60
+ vscode-jsonrpc: 8.2.1
+ zod: 4.4.3
+
+ '@github/copilot-win32-arm64@1.0.60':
+ optional: true
+
+ '@github/copilot-win32-x64@1.0.60':
+ optional: true
+
+ '@github/copilot@1.0.60':
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ '@github/copilot-darwin-arm64': 1.0.60
+ '@github/copilot-darwin-x64': 1.0.60
+ '@github/copilot-linux-arm64': 1.0.60
+ '@github/copilot-linux-x64': 1.0.60
+ '@github/copilot-linuxmusl-arm64': 1.0.60
+ '@github/copilot-linuxmusl-x64': 1.0.60
+ '@github/copilot-win32-arm64': 1.0.60
+ '@github/copilot-win32-x64': 1.0.60
+
'@hono/node-server@1.19.14(hono@4.12.25)':
dependencies:
hono: 4.12.25
@@ -19353,6 +19462,8 @@ snapshots:
vscode-jsonrpc@8.2.0: {}
+ vscode-jsonrpc@8.2.1: {}
+
vscode-languageserver-protocol@3.17.5:
dependencies:
vscode-jsonrpc: 8.2.0
diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts
index 974f3d036f0..2939c873448 100644
--- a/scripts/build-desktop-artifact.test.ts
+++ b/scripts/build-desktop-artifact.test.ts
@@ -109,6 +109,23 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
);
});
+ it("keeps the GitHub Copilot CLI as an explicit staged runtime dependency", () => {
+ assert.deepStrictEqual(
+ resolveDesktopRuntimeDependencies(
+ {
+ "@github/copilot": "1.0.60",
+ "@github/copilot-sdk": "1.0.0",
+ "@t3tools/contracts": "workspace:*",
+ },
+ {},
+ ),
+ {
+ "@github/copilot": "1.0.60",
+ "@github/copilot-sdk": "1.0.0",
+ },
+ );
+ });
+
it("carries only staged dependency patch metadata into staged desktop installs", () => {
assert.deepStrictEqual(
createStagePnpmConfig(