Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes).

Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them.
Works with your subscriptions on Claude Code, Codex, GitHub Copilot, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them.

## "Wait, what are you selling me?"

Expand All @@ -13,10 +13,11 @@ We wanted something performant, remote-ready, and truly open. If we ever go the
## Installation

> [!WARNING]
> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use:
> T3 Code currently supports Codex, Claude, GitHub Copilot (Early Access), Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use:
>
> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
> - GitHub Copilot: install [Copilot CLI](https://github.com/github/copilot-cli) and run `copilot login`
> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login`
> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login`
> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`
Expand Down Expand Up @@ -74,6 +75,7 @@ Full docs live in [docs/](./docs). There's no docs site yet.
- [Keeping app and server in sync](./docs/user/updating.md)
- [Source control integrations](./docs/user/source-control.md)
- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md)
- [GitHub Copilot provider](./docs/user/providers-copilot.md)
- Linux: [run T3 Code as a background service](./docs/user/background-service.md)

Building from source? Start at [docs/internals/overview.md](./docs/internals/overview.md).
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}

if (props.provider === "copilot") {
return (
<Svg width={size} height={size} viewBox="0 0 256 208" fill="none">
<Path
fill={mono}
d="M205.3 31.4c14 14.8 20 35.2 22.5 63.6 6.6 0 12.8 1.5 17 7.2l7.8 10.6c2.2 3 3.4 6.6 3.4 10.4v28.7a12 12 0 0 1-4.8 9.5C215.9 187.2 172.3 208 128 208c-49 0-98.2-28.3-123.2-46.6a12 12 0 0 1-4.8-9.5v-28.7c0-3.8 1.2-7.4 3.4-10.5l7.8-10.5c4.2-5.7 10.4-7.2 17-7.2 2.5-28.4 8.4-48.8 22.5-63.6C77.3 3.2 112.6 0 127.6 0h.4c14.7 0 50.4 2.9 77.3 31.4ZM128 78.7c-3 0-6.5.2-10.3.6a27.1 27.1 0 0 1-6 12.1 45 45 0 0 1-32 13c-6.8 0-13.9-1.5-19.7-5.2-5.5 1.9-10.8 4.5-11.2 11-.5 12.2-.6 24.5-.6 36.8 0 6.1 0 12.3-.2 18.5 0 3.6 2.2 6.9 5.5 8.4C79.9 185.9 105 192 128 192s48-6 74.5-18.1a9.4 9.4 0 0 0 5.5-8.4c.3-18.4 0-37-.8-55.3-.4-6.6-5.7-9.1-11.2-11-5.8 3.7-13 5.1-19.7 5.1a45 45 0 0 1-32-12.9 27.1 27.1 0 0 1-6-12.1c-3.4-.4-6.9-.5-10.3-.6Zm-27 44c5.8 0 10.5 4.6 10.5 10.4v19.2a10.4 10.4 0 0 1-20.8 0V133c0-5.8 4.6-10.4 10.4-10.4Zm53.4 0c5.8 0 10.4 4.6 10.4 10.4v19.2a10.4 10.4 0 0 1-20.8 0V133c0-5.8 4.7-10.4 10.4-10.4Zm-73-94.4c-11.2 1.1-20.6 4.8-25.4 10-10.4 11.3-8.2 40.1-2.2 46.2A31.2 31.2 0 0 0 75 91.7c6.8 0 19.6-1.5 30.1-12.2 4.7-4.5 7.5-15.7 7.2-27-.3-9.1-2.9-16.7-6.7-19.9-4.2-3.6-13.6-5.2-24.2-4.3Zm69 4.3c-3.8 3.2-6.4 10.8-6.7 19.9-.3 11.3 2.5 22.5 7.2 27a41.7 41.7 0 0 0 30 12.2c8.9 0 17-2.9 21.3-7.2 6-6.1 8.2-34.9-2.2-46.3-4.8-5-14.2-8.8-25.4-9.9-10.6-1-20 .7-24.2 4.3ZM128 56c-2.6 0-5.6.2-9 .5.4 1.7.5 3.7.7 5.7 0 1.5 0 3-.2 4.5 3.2-.3 6-.3 8.5-.3 2.6 0 5.3 0 8.5.3-.2-1.6-.2-3-.2-4.5.2-2 .3-4 .7-5.7-3.4-.3-6.4-.5-9-.5Z"
/>
</Svg>
);
}

if (props.provider === "opencode") {
return (
<Svg width={size} height={size} viewBox="0 0 32 40" fill="none">
Expand Down
9 changes: 5 additions & 4 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,11 @@ const permissionOptionIds = {
allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",
rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once",
};
const permissionToolKind = process.env.T3_ACP_PERMISSION_TOOL_KIND === "edit" ? "edit" : "execute";
const sessionId = "mock-session-1";

let currentModeId = "ask";
let currentModelId = "default";
let currentModelId = process.env.T3_ACP_INITIAL_MODEL_ID?.trim() || "default";
let parameterizedModelPicker = false;
let currentReasoning = "medium";
let currentContext = "272k";
Expand Down Expand Up @@ -982,7 +983,7 @@ const program = Effect.gen(function* () {
sessionUpdate: "tool_call",
toolCallId,
title: "Terminal",
kind: "execute",
kind: permissionToolKind,
status: "pending",
rawInput: {
command: ["cat", "server/package.json"],
Expand All @@ -1004,7 +1005,7 @@ const program = Effect.gen(function* () {
toolCall: {
toolCallId,
title: "`cat server/package.json`",
kind: "execute",
kind: permissionToolKind,
status: "pending",
content: [
{
Expand Down Expand Up @@ -1041,7 +1042,7 @@ const program = Effect.gen(function* () {
sessionUpdate: "tool_call_update",
toolCallId,
title: "Terminal",
kind: "execute",
kind: permissionToolKind,
status: "completed",
rawOutput: {
exitCode: 0,
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@ export interface AcpAdapterV2Flavor {
Crypto.Crypto | Scope.Scope
>;
readonly resolveModelId?: (selection: ModelSelection) => string | undefined;
readonly configureSession?: (input: {
readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"];
readonly startResult: AcpSessionRuntimeStartResult;
readonly modelSelection: ModelSelection;
readonly runtimePolicy: ProviderAdapterV2RuntimePolicy;
}) => Effect.Effect<void, EffectAcpErrors.AcpError>;
readonly registerExtensions?: (
context: AcpAdapterV2ExtensionContext,
) => Effect.Effect<void, EffectAcpErrors.AcpError>;
Expand Down Expand Up @@ -4597,6 +4603,14 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV
modelSelection: ModelSelection,
runtimePolicy: ProviderAdapterV2RuntimePolicy,
) {
if (flavor.configureSession !== undefined) {
return yield* flavor.configureSession({
runtime,
startResult,
modelSelection,
runtimePolicy,
});
}
const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model;
if (
requestedModel.length > 0 &&
Expand Down
168 changes: 168 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/CopilotAdapterV2.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import { ChildProcessSpawner } from "effect/unstable/process";

import { ServerConfig } from "../../config.ts";
import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts";
import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts";
import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts";
import type { AcpAdapterV2RuntimeInput } from "./AcpAdapterV2.ts";
import {
COPILOT_DRIVER_KIND,
CopilotAdapterV2Driver,
makeCopilotAdapterV2,
makeCopilotAcpAdapterFlavor,
type CopilotAdapterV2Options,
} from "./CopilotAdapterV2.ts";

const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
prefix: "t3-copilot-v2-adapter-",
}).pipe(Layer.provide(NodeServices.layer));

const testLayer = Layer.mergeAll(NodeServices.layer, idAllocatorLayer, serverConfigLayer);

function makeMockRuntime(input: {
readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
readonly mockAgentPath: string;
}) {
return (runtimeInput: AcpAdapterV2RuntimeInput) =>
Effect.gen(function* () {
const context = yield* Layer.build(
AcpSessionRuntime.layer({
...runtimeInput,
spawn: {
command: process.execPath,
args: [input.mockAgentPath],
cwd: runtimeInput.cwd,
env: { T3_ACP_SESSION_LIFECYCLE: "1" },
},
authMethodId: "test",
}).pipe(
Layer.provide(
Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
),
),
);
const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
Effect.provide(context),
);
return runtime;
});
}

describe("CopilotAdapterV2", () => {
it("is registered with Copilot schema defaults", () => {
assert.isTrue(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(COPILOT_DRIVER_KIND));
assert.equal(CopilotAdapterV2Driver.driverKind, COPILOT_DRIVER_KIND);
assert.deepEqual(CopilotAdapterV2Driver.defaultConfig(), {
enabled: true,
binaryPath: "copilot",
customModels: [],
});
});

it.effect("keeps Copilot in agent mode even for a plan interaction request", () =>
Effect.gen(function* () {
const selectedModes: Array<string> = [];
const instanceId = ProviderInstanceId.make("copilot-mode-fixture");
const flavor = makeCopilotAcpAdapterFlavor({
makeRuntime: () => Effect.never,
} as unknown as CopilotAdapterV2Options);
const runtime = {
getConfigOptions: Effect.succeed([]),
getModeState: Effect.succeed({
currentModeId: "plan",
availableModes: [
{ id: "agent", name: "Agent" },
{ id: "plan", name: "Plan" },
],
}),
setConfigOption: () => Effect.succeed({ configOptions: [] }),
setMode: (modeId: string) =>
Effect.sync(() => {
selectedModes.push(modeId);
return {};
}),
setModel: () => Effect.void,
} as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
const configureSession = flavor.configureSession;
if (configureSession === undefined) {
return yield* Effect.die("Expected Copilot ACP session configuration");
}

yield* configureSession({
runtime,
startResult: {} as AcpSessionRuntime.AcpSessionRuntimeStartResult,
modelSelection: { instanceId, model: "auto" },
runtimePolicy: ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "approval-required",
interactionMode: "plan",
cwd: process.cwd(),
}),
});

assert.deepEqual(selectedModes, ["agent"]);
assert.isFalse(flavor.capabilities.planning.emitsProposedPlan);
}),
);

it.effect("opens a Copilot V2 session through the shared ACP adapter", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fileSystem = yield* FileSystem.FileSystem;
const idAllocator = yield* IdAllocatorV2;
const path = yield* Path.Path;
const serverConfig = yield* ServerConfig;
const mockAgentPath = yield* path.fromFileUrl(
new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
);
const instanceId = ProviderInstanceId.make("copilot-fixture");
const adapter = makeCopilotAdapterV2({
instanceId,
settings: {
enabled: true,
binaryPath: "copilot",
customModels: [],
},
environment: {},
childProcessSpawner,
crypto: yield* Crypto.Crypto,
fileSystem,
idAllocator,
serverConfig,
makeRuntime: makeMockRuntime({
childProcessSpawner,
mockAgentPath,
}),
});
const threadId = ThreadId.make("thread-copilot-fixture");
const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
interactionMode: "plan",
cwd: process.cwd(),
});
const modelSelection = { instanceId, model: "default" } as const;
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("provider-session-copilot-fixture"),
modelSelection,
runtimePolicy,
});
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection,
runtimePolicy,
});

assert.equal(runtime.providerSession.driver, "copilot");
assert.equal(providerThread.nativeThreadRef?.nativeId, "mock-session-1");
}).pipe(Effect.provide(testLayer), Effect.scoped),
);
});
Loading
Loading