{/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */}
diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx
index d73f0f16b28f..0f7e246c7980 100644
--- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx
+++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx
@@ -18,13 +18,17 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
? "Command approval"
: approval.requestKind === "file-read"
? "File read approval"
- : "File change approval";
+ : approval.requestKind === "plan"
+ ? "Plan approval"
+ : "File change approval";
const detailAriaLabel =
approval.requestKind === "command"
? "Command"
: approval.requestKind === "file-read"
? "File to read"
- : "File change";
+ : approval.requestKind === "plan"
+ ? "Plan"
+ : "File change";
return (
> = {
@@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial
[ProviderDriverKind.make("opencode")]: OpenCodeIcon,
[ProviderDriverKind.make("cursor")]: CursorIcon,
[ProviderDriverKind.make("grok")]: GrokIcon,
+ [ProviderDriverKind.make("droid")]: DroidIcon,
};
function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {
diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx
index 9c36d32ff51a..d4f1368744ac 100644
--- a/apps/web/src/components/settings/DiagnosticsSettings.tsx
+++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx
@@ -299,7 +299,7 @@ function formatProcessName(command: string): string {
function formatProcessType(process: ServerProcessDiagnosticsEntry): string {
if (process.depth > 0) return "Subprocess";
- if (/\b(codex|claude|opencode|cursor)\b/i.test(process.command)) return "Agent";
+ if (/\b(codex|claude|opencode|cursor|droid)\b/i.test(process.command)) return "Agent";
return "Process";
}
diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx
index 9a42961d13ee..3da65be21af8 100644
--- a/apps/web/src/components/settings/ProviderModelsSection.tsx
+++ b/apps/web/src/components/settings/ProviderModelsSection.tsx
@@ -35,6 +35,7 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial>;
@@ -67,6 +76,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] =
icon: OpenCodeIcon,
settingsSchema: OpenCodeSettings,
},
+ {
+ value: ProviderDriverKind.make("droid"),
+ label: "Droid",
+ icon: DroidIcon,
+ badgeLabel: "Early Access",
+ settingsSchema: DroidSettings,
+ },
];
export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial<
diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts
index 80f7d31cf2f9..06e7384269cc 100644
--- a/apps/web/src/lib/contextWindow.ts
+++ b/apps/web/src/lib/contextWindow.ts
@@ -1,3 +1,4 @@
+import { resolveProviderDisplayName } from "@t3tools/client-runtime/providerDisplayName";
import type { OrchestrationThreadActivity, ThreadTokenUsageSnapshot } from "@t3tools/contracts";
function asRecord(value: unknown): Record | null {
@@ -28,23 +29,10 @@ export type ContextWindowSnapshot = NullableContextWindowUsage & {
/** Map a provider driver kind to a user-facing display name. */
export function formatProviderDisplayName(provider: string | null | undefined): string {
if (!provider) return "This agent";
- switch (provider) {
- case "claudeAgent":
- case "claude":
- return "Claude";
- case "codex":
- return "Codex";
- case "cursor":
- return "Cursor";
- case "opencode":
- return "OpenCode";
- default: {
- // Title-case unknown driver kinds so they read reasonably.
- const trimmed = provider.replace(/Agent$/i, "").trim();
- if (trimmed.length === 0) return provider;
- return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
- }
- }
+ const trimmed = provider.replace(/Agent$/i, "").trim();
+ const fallback =
+ trimmed.length === 0 ? provider : trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
+ return resolveProviderDisplayName(provider, fallback);
}
export function deriveLatestContextWindowSnapshot(
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts
index e94712d3e4da..69ec4dfa12e9 100644
--- a/apps/web/src/session-logic.test.ts
+++ b/apps/web/src/session-logic.test.ts
@@ -130,6 +130,32 @@ describe("derivePendingApprovals", () => {
]);
});
+ it("maps plan approval requestType payloads into pending approvals", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "approval-open-plan-approval",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "approval.requested",
+ summary: "Plan approval requested",
+ tone: "approval",
+ payload: {
+ requestId: "req-plan-approval",
+ requestType: "plan_approval",
+ detail: "1. Map plan approvals\n2. Render the approval UI",
+ },
+ }),
+ ];
+
+ expect(derivePendingApprovals(activities)).toEqual([
+ {
+ requestId: "req-plan-approval",
+ requestKind: "plan",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ detail: "1. Map plan approvals\n2. Render the approval UI",
+ },
+ ]);
+ });
+
it("derives dynamic tool requests as actionable generic approvals", () => {
const activities: OrchestrationThreadActivity[] = [
makeActivity({
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index 4824258422fb..a92249003f36 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -1,5 +1,9 @@
import * as Option from "effect/Option";
import * as Arr from "effect/Array";
+import {
+ approvalRequestKindFromPayload,
+ type ApprovalRequestKind,
+} from "@t3tools/client-runtime/approvalRequests";
import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";
import {
ApprovalRequestId,
@@ -52,6 +56,12 @@ export const PROVIDER_OPTIONS: Array<{
available: true,
pickerSidebarBadge: "new",
},
+ {
+ value: ProviderDriverKind.make("droid"),
+ label: "Droid",
+ available: true,
+ pickerSidebarBadge: "new",
+ },
];
export type WorkLogToolLifecycleStatus =
@@ -109,7 +119,7 @@ interface DerivedWorkLogEntry extends WorkLogEntry {
export interface PendingApproval {
requestId: ApprovalRequestId;
- requestKind: "command" | "file-read" | "file-change";
+ requestKind: ApprovalRequestKind;
createdAt: string;
detail?: string;
}
@@ -367,22 +377,6 @@ export function deriveActiveWorkStartedAt(
return sendStartedAt;
}
-function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null {
- switch (requestType) {
- case "command_execution_approval":
- case "exec_command_approval":
- case "dynamic_tool_call":
- return "command";
- case "file_read_approval":
- return "file-read";
- case "file_change_approval":
- case "apply_patch_approval":
- return "file-change";
- default:
- return null;
- }
-}
-
function isStalePendingRequestFailureDetail(detail: string | undefined): boolean {
const normalized = detail?.toLowerCase();
if (!normalized) {
@@ -414,15 +408,7 @@ export function derivePendingApprovals(
payload && typeof payload.requestId === "string"
? ApprovalRequestId.make(payload.requestId)
: null;
- const requestKind =
- payload &&
- (payload.requestKind === "command" ||
- payload.requestKind === "file-read" ||
- payload.requestKind === "file-change")
- ? payload.requestKind
- : payload
- ? requestKindFromRequestType(payload.requestType)
- : null;
+ const requestKind = approvalRequestKindFromPayload(payload);
const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;
if (activity.kind === "approval.requested" && requestId && requestKind) {
@@ -1671,14 +1657,7 @@ function extractWorkLogItemType(
function extractWorkLogRequestKind(
payload: Record | null,
): WorkLogEntry["requestKind"] | undefined {
- if (
- payload?.requestKind === "command" ||
- payload?.requestKind === "file-read" ||
- payload?.requestKind === "file-change"
- ) {
- return payload.requestKind;
- }
- return requestKindFromRequestType(payload?.requestType) ?? undefined;
+ return approvalRequestKindFromPayload(payload) ?? undefined;
}
function pushChangedFile(target: string[], seen: Set, value: unknown) {
diff --git a/docs/README.md b/docs/README.md
index 622d81064387..9a3e1a472e81 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -13,7 +13,8 @@
- [Keeping app and server in sync](./user/updating.md)
- [Source control integrations](./user/source-control.md)
- [Background service (Linux)](./user/background-service.md)
-- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md)
+- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) ·
+ [Droid](./user/providers-droid.md)
Mobile app: [apps/mobile/README.md](../apps/mobile/README.md)
diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md
index da16f74d339f..4d13a954591c 100644
--- a/docs/internals/glossary.md
+++ b/docs/internals/glossary.md
@@ -94,7 +94,14 @@ The live backend agent implementation and its event stream. The main service is
#### Provider
-The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter.
+The backend agent runtime that actually performs work. Six drivers ship built in: Codex, Claude,
+Cursor, Droid, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and
+[CodexAdapter.ts][17] as a representative adapter.
+
+#### Factory home
+
+The per-user base directory used by Factory Droid for settings and credentials. It normally lives
+under the operating-system user's home directory as `.factory`.
#### Session
diff --git a/docs/internals/overview.md b/docs/internals/overview.md
index b9454f7b58d0..9bc243b84bf5 100644
--- a/docs/internals/overview.md
+++ b/docs/internals/overview.md
@@ -18,13 +18,13 @@ there, never in the client.
┌──────────────────▼─────────────────────────────┐
│ apps/server │
│ orchestration engine (event-sourced) │
-│ provider driver registry (5 built-in drivers) │
+│ provider driver registry (6 built-in drivers) │
│ checkpointing, VCS, terminals, filesystem │
└──────────────────┬─────────────────────────────┘
│ per-driver transport
┌──────────────────▼─────────────────────────────┐
-│ Agent CLIs: Codex, Claude, Cursor, Grok, │
-│ OpenCode │
+│ Agent CLIs: Codex, Claude, Cursor, Droid, │
+│ Grok, OpenCode │
└────────────────────────────────────────────────┘
```
@@ -106,11 +106,11 @@ build production behavior on receipts.
## Provider drivers
-Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`:
-Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a
-scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves
-an instance to its adapter, so `ProviderService` routes session and turn operations without knowing
-which agent is behind them. See [providers.md](./providers.md).
+Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`:
+Codex, Claude, Cursor, Droid, Grok, and OpenCode. A driver declares its kind and config schema and
+creates a scoped adapter; `ProviderInstanceRegistry` owns live instances and
+`ProviderAdapterRegistry` resolves an instance to its adapter, so `ProviderService` routes session
+and turn operations without knowing which agent is behind them. See [providers.md](./providers.md).
## Checkpointing
diff --git a/docs/internals/providers.md b/docs/internals/providers.md
index a309d70f03de..20cf979450a3 100644
--- a/docs/internals/providers.md
+++ b/docs/internals/providers.md
@@ -7,15 +7,16 @@ orchestration layer does not know which one is behind a thread.
## Built-in drivers
-[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries:
+[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries:
-| Driver kind | Driver source |
-| ------------- | --------------------------------------- |
-| `codex` | [`Drivers/CodexDriver.ts`][codex] |
-| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] |
-| `cursor` | [`Drivers/CursorDriver.ts`][cursor] |
-| `grok` | [`Drivers/GrokDriver.ts`][grok] |
-| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] |
+| Driver kind | Driver source | Adapter source |
+| ------------- | --------------------------------------- | ----------------------------------------- |
+| `codex` | [`Drivers/CodexDriver.ts`][codex] | `Layers/CodexAdapter.ts` |
+| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | `Layers/ClaudeAdapter.ts` |
+| `cursor` | [`Drivers/CursorDriver.ts`][cursor] | `Layers/CursorAdapter.ts` |
+| `droid` | [`Drivers/DroidDriver.ts`][droid] | [`Layers/DroidAdapter.ts`][droid-adapter] |
+| `grok` | [`Drivers/GrokDriver.ts`][grok] | `Layers/GrokAdapter.ts` |
+| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | `Layers/OpenCodeAdapter.ts` |
Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an
adapter in a child scope. Adapter implementations live beside them in
@@ -79,6 +80,8 @@ when a request opens (approval) or user input is requested, via
[codex]: ../../apps/server/src/provider/Drivers/CodexDriver.ts
[claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts
[cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts
+[droid]: ../../apps/server/src/provider/Drivers/DroidDriver.ts
+[droid-adapter]: ../../apps/server/src/provider/Layers/DroidAdapter.ts
[grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts
[opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts
[adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts
diff --git a/docs/user/install.md b/docs/user/install.md
index 15f96e00d4f3..255905427aaf 100644
--- a/docs/user/install.md
+++ b/docs/user/install.md
@@ -54,15 +54,18 @@ yay -S t3code-nightly-bin
T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want
to use, then authenticate it.
-| Provider | CLI | Default binary | Log in with |
-| ---------- | ----------------------------------------------------- | -------------- | --------------------- |
-| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` |
-| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` |
-| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` |
-| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` |
-| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` |
-
-Codex and Claude are on by default. Cursor, Grok Build, and OpenCode are off by default; turn
+| Provider | CLI | Install | Default binary | Log in with |
+| ---------- | ----------------------------------------------------- | --------------------------------------------- | -------------- | --------------------------------------- |
+| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | See provider instructions | `codex` | `codex login` |
+| Claude | [Claude Code](https://claude.com/product/claude-code) | See provider instructions | `claude` | `claude auth login` |
+| Cursor | [Cursor CLI](https://cursor.com/cli) | See provider instructions | `cursor-agent` | `agent login` |
+| Droid | [Factory Droid](https://www.factory.ai/) | `curl -fsSL https://app.factory.ai/cli \| sh` | `droid` | Run `droid` and sign in in your browser |
+| Grok Build | [Grok Build CLI](https://x.ai/cli) | See provider instructions | `grok` | `grok login` |
+| OpenCode | [OpenCode](https://opencode.ai) | See provider instructions | `opencode` | `opencode auth login` |
+
+On Windows, install Droid with `irm https://app.factory.ai/cli/windows | iex`.
+
+Codex, Claude, and Cursor are on by default. Droid, Grok Build, and OpenCode are off by default; turn
them on in **Settings** → the provider's card when you want to use them.
Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that
@@ -85,7 +88,8 @@ T3 Code. You can install T3 Code, open it, and add providers afterwards. A provi
authenticated shows its status in **Settings** and fails at session start with the login command
to run.
-For multi-account setups, see [Codex](./providers-codex.md) and [Claude](./providers-claude.md).
+For provider-specific setup, see [Codex](./providers-codex.md),
+[Claude](./providers-claude.md), and [Droid](./providers-droid.md).
## Next Steps
diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md
index 0648bafc8b77..41e8da4599aa 100644
--- a/docs/user/permission-modes.md
+++ b/docs/user/permission-modes.md
@@ -18,13 +18,17 @@ without prompting; commands and anything else still stop for approval.
**Auto**: routine actions proceed without you; risky ones still ask. How this is enforced depends
on the provider: Codex delegates routine approvals to an AI reviewer, Claude uses its own auto
permission mode, and providers without an equivalent (such as OpenCode) fall back to asking, like
-Supervised.
+Supervised. Droid allows edits and read-only commands in **Auto-accept edits**, adds reversible
+commands in **Auto**, and only runs every command without prompting in **Full access**.
**Full access**: allow commands and edits without prompts. The default. The agent runs
unattended until it finishes or asks a question of its own.
-Approvals appear inline in the conversation. Approve or reject one and the agent continues from
-there.
+For Droid, **Full access** selects its highest autonomy level. T3 Code does not pass Droid's
+`--skip-permissions-unsafe` override.
+
+Approvals appear inline in the conversation. Depending on the provider and request, rejecting one
+may end the current turn instead of letting the agent continue in place.
## Choosing a Mode
diff --git a/docs/user/providers-droid.md b/docs/user/providers-droid.md
new file mode 100644
index 000000000000..fea39b117f88
--- /dev/null
+++ b/docs/user/providers-droid.md
@@ -0,0 +1,108 @@
+# Droid
+
+Droid is Factory's coding agent. T3 Code connects to the Factory Droid CLI on the machine running
+the server, so you can use your own Factory subscription while working from the web, desktop, or
+mobile app.
+
+Droid support is in Early Access. Enable it from the Droid provider card in Settings after
+installing and authenticating the CLI.
+
+## Install And Log In
+
+Install Factory Droid.
+
+macOS and Linux:
+
+```bash
+curl -fsSL https://app.factory.ai/cli | sh
+```
+
+Windows:
+
+```powershell
+irm https://app.factory.ai/cli/windows | iex
+```
+
+Installations from these commands support automatic updates. Run `droid update` to check and update
+manually.
+
+Then start Droid in a terminal:
+
+```bash
+droid
+```
+
+Follow the browser sign-in flow. Run this on the machine that runs the T3 Code server. Droid stores
+the resulting Factory account credentials in that user's Factory home.
+
+For automation, set `FACTORY_API_KEY` in the Droid provider's Environment variables section in
+Settings. Mark it as sensitive so T3 Code stores it as a server secret and does not send it back to
+the app after saving. When both are present, the API key takes precedence over the stored Factory
+account login.
+
+## Models And Reasoning
+
+T3 Code fetches the available models from Droid dynamically. Each model advertises the reasoning
+efforts it supports, and those choices appear with the model in the picker. The list can change as
+Factory adds or updates models without requiring a T3 Code update.
+
+You can change the model or reasoning effort in an existing thread. T3 Code applies the new choice
+before it sends the next message to Droid.
+
+## Slash Commands And Skills
+
+T3 Code reads your Droid slash commands and skills when it checks the provider, so they appear in the
+composer alongside every other provider's. Custom commands keep their argument hints, and skills keep
+their descriptions and source. Skills Droid does not let you invoke directly, such as its built-ins,
+stay out of the list.
+
+Commands and skills resolve on the machine running the server against the server's working
+directory, so project-local entries are discovered alongside personal ones. Add a command or skill,
+refresh the Droid card in Settings, and it shows up.
+
+## Permission Modes
+
+T3 Code maps its permission modes onto Droid's command confirmation levels:
+
+| T3 Code mode | Droid behavior |
+| ------------------------------ | ------------------------------------------------- |
+| Supervised (approval required) | Confirms every command and file change |
+| Auto-accept edits | Automatically allows edits and read-only commands |
+| Auto | Also allows reversible commands without prompting |
+| Full access | Allows all commands without prompting |
+
+Approvals appear inline in the conversation. Rejecting one cancels the current turn; send another
+message to tell Droid how to proceed.
+
+## Plan Mode
+
+When T3 Code's plan mode is enabled, it uses Droid's Spec Mode. Droid researches and writes a plan
+before implementation, then presents the plan approval as an approval request in the conversation.
+Approve it to begin implementation. Rejecting it cancels the turn; send another message in plan mode
+to refine the plan. On approval, Droid hands the work to an implementation session in the same
+thread; the turn keeps streaming and the thread resumes onto the implementation conversation
+afterwards.
+
+## Context And Subagents
+
+Droid compacts long conversations automatically, so the context meter shows the live context after
+compaction rather than lifetime usage. When Droid delegates work to a subagent, it appears as a task
+in the conversation with its own completion state.
+
+If you send another message while Droid is working, T3 Code treats it as steering for the active
+turn. Droid may fold it into the current run or process it immediately afterwards.
+
+## Session Resume
+
+Droid sessions resume across T3 Code server restarts. Reopen the same thread and continue where you
+left off instead of starting a new Droid conversation.
+
+After a session resumes, rollback can only target turns completed since T3 Code most recently loaded
+that Droid session. Earlier turns remain in the conversation, but T3 Code cannot use them as Droid
+rollback points.
+
+## Early Access
+
+Droid support is still evolving. Model metadata, reasoning choices, approval behavior, and session
+resume may change as the Factory CLI develops. If a session behaves unexpectedly, update Factory
+Droid, refresh its status in Settings, and start a new thread if the existing session cannot resume.
diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json
index abed33998966..deaab5de3a44 100644
--- a/packages/client-runtime/package.json
+++ b/packages/client-runtime/package.json
@@ -43,6 +43,14 @@
"types": "./src/providerSkills.ts",
"default": "./src/providerSkills.ts"
},
+ "./approvalRequests": {
+ "types": "./src/approvalRequests.ts",
+ "default": "./src/approvalRequests.ts"
+ },
+ "./providerDisplayName": {
+ "types": "./src/providerDisplayName.ts",
+ "default": "./src/providerDisplayName.ts"
+ },
"./relay": {
"types": "./src/relay/index.ts",
"default": "./src/relay/index.ts"
diff --git a/packages/client-runtime/src/approvalRequests.ts b/packages/client-runtime/src/approvalRequests.ts
new file mode 100644
index 000000000000..c7cf4461c18f
--- /dev/null
+++ b/packages/client-runtime/src/approvalRequests.ts
@@ -0,0 +1,36 @@
+export type ApprovalRequestKind = "command" | "file-read" | "file-change" | "plan";
+
+/**
+ * Reads the client-facing approval kind from an orchestration activity payload.
+ * Dynamic tool calls use the generic executable-action bucket so they remain
+ * actionable on clients that do not render provider-specific tool kinds.
+ */
+export function approvalRequestKindFromPayload(
+ payload: Readonly> | null | undefined,
+): ApprovalRequestKind | null {
+ const requestKind = payload?.requestKind;
+ if (
+ requestKind === "command" ||
+ requestKind === "file-read" ||
+ requestKind === "file-change" ||
+ requestKind === "plan"
+ ) {
+ return requestKind;
+ }
+
+ switch (payload?.requestType) {
+ case "command_execution_approval":
+ case "exec_command_approval":
+ case "dynamic_tool_call":
+ return "command";
+ case "file_read_approval":
+ return "file-read";
+ case "file_change_approval":
+ case "apply_patch_approval":
+ return "file-change";
+ case "plan_approval":
+ return "plan";
+ default:
+ return null;
+ }
+}
diff --git a/packages/client-runtime/src/providerDisplayName.test.ts b/packages/client-runtime/src/providerDisplayName.test.ts
new file mode 100644
index 000000000000..9eeb890776e3
--- /dev/null
+++ b/packages/client-runtime/src/providerDisplayName.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveProviderDisplayName } from "./providerDisplayName.ts";
+
+describe("resolveProviderDisplayName", () => {
+ it("uses canonical built-in names, including the historical claude alias", () => {
+ expect(resolveProviderDisplayName("droid", "droid")).toBe("Droid");
+ expect(resolveProviderDisplayName("grok", "grok")).toBe("Grok");
+ expect(resolveProviderDisplayName("claude", "claude")).toBe("Claude");
+ });
+
+ it("preserves the caller's fallback for custom drivers", () => {
+ expect(resolveProviderDisplayName("acmeAgent", "Acme Agent")).toBe("Acme Agent");
+ });
+});
diff --git a/packages/client-runtime/src/providerDisplayName.ts b/packages/client-runtime/src/providerDisplayName.ts
new file mode 100644
index 000000000000..364082417d80
--- /dev/null
+++ b/packages/client-runtime/src/providerDisplayName.ts
@@ -0,0 +1,10 @@
+import { PROVIDER_DISPLAY_NAMES, ProviderDriverKind } from "@t3tools/contracts";
+
+/**
+ * Resolves a raw driver slug through the canonical built-in display-name table.
+ * Callers provide the surface-appropriate fallback for custom drivers.
+ */
+export function resolveProviderDisplayName(driver: string, fallback: string): string {
+ const driverKind = ProviderDriverKind.make(driver === "claude" ? "claudeAgent" : driver);
+ return PROVIDER_DISPLAY_NAMES[driverKind] ?? fallback;
+}
diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts
index 9fcd0d266dd6..bd671c9c0528 100644
--- a/packages/contracts/src/model.ts
+++ b/packages/contracts/src/model.ts
@@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");
+const DROID_DRIVER_KIND = ProviderDriverKind.make("droid");
export const DEFAULT_MODEL = "gpt-5.6-sol";
@@ -153,6 +154,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial>
[CURSOR_DRIVER_KIND]: "Cursor",
[GROK_DRIVER_KIND]: "Grok",
[OPENCODE_DRIVER_KIND]: "OpenCode",
+ [DROID_DRIVER_KIND]: "Droid",
};
diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts
index bd525e6542e2..b718bac4d28e 100644
--- a/packages/contracts/src/providerRuntime.ts
+++ b/packages/contracts/src/providerRuntime.ts
@@ -26,6 +26,8 @@ const RuntimeEventRawSource = Schema.Union([
Schema.Literal("claude.sdk.permission"),
Schema.Literal("codex.sdk.thread-event"),
Schema.Literal("opencode.sdk.event"),
+ Schema.Literal("droid.jsonrpc.notification"),
+ Schema.Literal("droid.jsonrpc.request"),
Schema.Literal("acp.jsonrpc"),
Schema.TemplateLiteral(["acp.", Schema.String, ".extension"]),
]);
@@ -138,6 +140,7 @@ export const CanonicalRequestType = Schema.Literals([
"file_change_approval",
"apply_patch_approval",
"exec_command_approval",
+ "plan_approval",
"tool_user_input",
"dynamic_tool_call",
"auth_tokens_refresh",
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 55023bcc48e7..68484bba73b9 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -203,12 +203,14 @@ describe("provider enabled defaults", () => {
expect(decoded.providers.cursor.enabled).toBe(true);
expect(decoded.providers.grok.enabled).toBe(false);
expect(decoded.providers.opencode.enabled).toBe(false);
+ expect(decoded.providers.droid.enabled).toBe(false);
});
it("derives per-driver defaults from the settings schemas", () => {
expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true);
expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(true);
expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false);
+ expect(defaultEnabledForDriver(ProviderDriverKind.make("droid"))).toBe(false);
// Unknown fork drivers stay enabled; their own build decides otherwise.
expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true);
});
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index ba4facaf53ce..2505ac873606 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -530,6 +530,33 @@ export const OpenCodeSettings = makeProviderSettingsSchema(
);
export type OpenCodeSettings = typeof OpenCodeSettings.Type;
+export const DroidSettings = makeProviderSettingsSchema(
+ {
+ // Off by default (like Cursor, Grok, and OpenCode): the binding is not
+ // yet stable enough to probe on every install. Users opt in from
+ // Settings.
+ enabled: Schema.Boolean.pipe(
+ Schema.withDecodingDefault(Effect.succeed(false)),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ binaryPath: makeBinaryPathSetting("droid").pipe(
+ Schema.annotateKey({
+ title: "Binary path",
+ description: "Path to the Factory Droid CLI binary.",
+ providerSettingsForm: { placeholder: "droid", clearWhenEmpty: "omit" },
+ }),
+ ),
+ customModels: Schema.Array(Schema.String).pipe(
+ Schema.withDecodingDefault(Effect.succeed([])),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ },
+ {
+ order: ["binaryPath"],
+ },
+);
+export type DroidSettings = typeof DroidSettings.Type;
+
export const ObservabilitySettings = Schema.Struct({
otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
@@ -672,6 +699,7 @@ export const ServerSettings = Schema.Struct({
cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
+ droid: DroidSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
}).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
// New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values
// are `ProviderInstanceConfig` envelopes. The driver-specific config blob
@@ -819,6 +847,12 @@ const OpenCodeSettingsPatch = Schema.Struct({
customModels: Schema.optionalKey(Schema.Array(Schema.String)),
});
+const DroidSettingsPatch = Schema.Struct({
+ enabled: Schema.optionalKey(Schema.Boolean),
+ binaryPath: Schema.optionalKey(TrimmedString),
+ customModels: Schema.optionalKey(Schema.Array(Schema.String)),
+});
+
export const ServerSettingsPatch = Schema.Struct({
// Server settings
enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean),
@@ -860,6 +894,7 @@ export const ServerSettingsPatch = Schema.Struct({
cursor: Schema.optionalKey(CursorSettingsPatch),
grok: Schema.optionalKey(GrokSettingsPatch),
opencode: Schema.optionalKey(OpenCodeSettingsPatch),
+ droid: Schema.optionalKey(DroidSettingsPatch),
}),
),
// Whole-map replacement for the new instance config. Patching individual