diff --git a/AGENTS.md b/AGENTS.md index b919730ebe749..a6d1999397950 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # T3 Code -T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity, Kiro) and serves web, desktop, and mobile clients. +T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity, Kiro, Mistral Vibe) and serves web, desktop, and mobile clients. You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor. @@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste - **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature. - **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime` -- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, Antigravity, and Kiro each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". +- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, Antigravity, Kiro, and Mistral Vibe each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". - **Agents.** A capability a user can trigger is usually one an agent should reach through MCP tools, and scheduled tasks run the same paths. That only works when it is a service method, not handler code. - **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. - **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. diff --git a/README.md b/README.md index 1f63f3609ae0d..acbc8b32ca90e 100644 --- a/README.md +++ b/README.md @@ -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, OpenCode, Google Antigravity, and fx. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, Google Antigravity, fx, and Mistral Vibe. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" @@ -13,7 +13,7 @@ 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, OpenCode, Antigravity, and fx. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, Antigravity, fx, and Mistral Vibe. 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` @@ -22,6 +22,7 @@ We wanted something performant, remote-ready, and truly open. If we ever go the > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` > - Antigravity: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. No CLI is required. > - fx: install [fx](https://fx.sh) and run `fx login` on the machine running the T3 Code server ([setup guide](./docs/user/providers-fx.md)) +> - Mistral Vibe: install [Mistral Vibe](https://github.com/mistralai/mistral-vibe) and run `vibe --setup` on the machine running the T3 Code server ([setup guide](./docs/user/providers-vibe.md)) ### Command line diff --git a/apps/server/package.json b/apps/server/package.json index c2e711e96cfe3..732ed25a3a740 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -51,6 +51,7 @@ "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", + "@t3tools/provider-vibe": "workspace:*", "@t3tools/source-control-azure-devops": "workspace:*", "@t3tools/source-control-bitbucket": "workspace:*", "@t3tools/source-control-core": "workspace:*", diff --git a/apps/server/src/policy/managedProviders.ts b/apps/server/src/policy/managedProviders.ts index ac390097e150d..5136f68dab1ce 100644 --- a/apps/server/src/policy/managedProviders.ts +++ b/apps/server/src/policy/managedProviders.ts @@ -20,6 +20,7 @@ import { cursorClient } from "@t3tools/provider-cursor/client"; import { fxClient } from "@t3tools/provider-fx/client"; import { grokClient } from "@t3tools/provider-grok/client"; import { kiroClient } from "@t3tools/provider-kiro/client"; +import { vibeClient } from "@t3tools/provider-vibe/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; @@ -47,6 +48,7 @@ const PACKAGED_CLIENTS: ReadonlyArray = [ grokClient, fxClient, kiroClient, + vibeClient, museClient, openCodeClient, piClient, diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 342a6780e9ad0..1450d54adcbdb 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -16,6 +16,7 @@ import type { ProviderUsageReaderEnv, } from "@t3tools/provider-core/server/driver"; import { KiroDriver, type KiroDriverEnv } from "@t3tools/provider-kiro/server"; +import { VibeDriver, type VibeDriverEnv } from "@t3tools/provider-vibe/server"; export type BuiltInDriversEnv = | AcpRegistryDriverEnv @@ -26,6 +27,7 @@ export type BuiltInDriversEnv = | FxDriverEnv | GrokDriverEnv | KiroDriverEnv + | VibeDriverEnv | OpenCodeDriverEnv | PiDriverEnv | MuseDriverEnv; @@ -39,6 +41,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray=2.27.0", "ranges": [{ "range": ">=2.27.0", "status": "supported" }] }, + { + "driver": "vibe", + "t3CodeRange": ">=0.1.7", + "recommendedRange": ">=3.0.3", + "ranges": [{ "range": ">=3.0.3", "status": "supported" }] + }, { "driver": "pi", "t3CodeRange": ">=0.0.46", diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 66e7d64d70a64..94558a28128cc 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -36,6 +36,7 @@ const BUILT_IN_DRIVER_ORDER: ReadonlyArray = [ "antigravity", "fx", "kiro", + "vibe", ]; const driverRank = (driver: string): number => { diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 17640237ff5ca..7b64a1c6788ed 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -15,6 +15,7 @@ import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; import { cursorClient } from "@t3tools/provider-cursor/client"; import { grokClient } from "@t3tools/provider-grok/client"; import { kiroClient } from "@t3tools/provider-kiro/client"; +import { vibeClient } from "@t3tools/provider-vibe/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; @@ -35,6 +36,7 @@ const providerSecretFields = new Map([ cursorClient, grokClient, kiroClient, + vibeClient, museClient, openCodeClient, piClient, diff --git a/docs/README.md b/docs/README.md index 48eb4deec9de1..b099a4a11d8f1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,7 +25,7 @@ - [Running in the background](./user/background-service.md) - [Updating T3 Code](./user/updating.md) - [Triage an issue](./user/triage.md) -- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) · [Kiro](./user/providers-kiro.md) · [Pi](./user/providers-pi.md) · [Muse Code](./user/providers-muse.md) · [fx](./user/providers-fx.md) +- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) · [Kiro](./user/providers-kiro.md) · [Pi](./user/providers-pi.md) · [Muse Code](./user/providers-muse.md) · [fx](./user/providers-fx.md) · [Mistral Vibe](./user/providers-vibe.md) --- diff --git a/docs/user/install.md b/docs/user/install.md index fde265746004e..150c02ef26158 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -158,18 +158,19 @@ and enable the provider you want. Installation, login, and configuration belong to that environment's machine, even when you connect from a phone or another computer. -| Provider | Install and authenticate | -| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Codex | [Connect with ChatGPT](./providers-codex.md#connect-with-chatgpt), or install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`. | -| Claude | Install [Claude Code](https://claude.com/product/claude-code), then run `claude auth login`. | -| Cursor | Install [Cursor CLI](https://cursor.com/cli), then run `agent login`. | -| Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. | -| Kiro | Install [Kiro CLI](https://kiro.dev/docs/cli/), then run `kiro-cli login`. | -| OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. | -| Antigravity | Install and sign in with Google from T3 Code's provider settings. | -| Pi | Install [Pi](./providers-pi.md#set-up-pi) with Node.js 22.19 or newer, then run `pi` and `/login`. | -| Muse Code | Install [Muse Code](https://dev.meta.ai/docs/muse-code) on the server, run `muse login`, then enable it in Settings → Providers. | -| fx | Install [fx](https://fx.sh) with `curl -fsSL https://fx.sh/setup.sh \| bash`, then run `fx login`. | +| Provider | Install and authenticate | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Codex | [Connect with ChatGPT](./providers-codex.md#connect-with-chatgpt), or install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`. | +| Claude | Install [Claude Code](https://claude.com/product/claude-code), then run `claude auth login`. | +| Cursor | Install [Cursor CLI](https://cursor.com/cli), then run `agent login`. | +| Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. | +| Kiro | Install [Kiro CLI](https://kiro.dev/docs/cli/), then run `kiro-cli login`. | +| Mistral Vibe | Install [Mistral Vibe](./providers-vibe.md#set-up-mistral-vibe), then run `vibe --setup`. | +| OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. | +| Antigravity | Install and sign in with Google from T3 Code's provider settings. | +| Pi | Install [Pi](./providers-pi.md#set-up-pi) with Node.js 22.19 or newer, then run `pi` and `/login`. | +| Muse Code | Install [Muse Code](https://dev.meta.ai/docs/muse-code) on the server, run `muse login`, then enable it in Settings → Providers. | +| fx | Install [fx](https://fx.sh) with `curl -fsSL https://fx.sh/setup.sh \| bash`, then run `fx login`. | Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set its **Binary path** in provider settings, especially when using a version manager. @@ -200,7 +201,8 @@ their original values. For provider-specific setup and accounts, see [Codex](./providers-codex.md), [Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), [Antigravity](./providers-antigravity.md), [Kiro](./providers-kiro.md), -[Pi](./providers-pi.md), [Muse Code](./providers-muse.md), and [fx](./providers-fx.md). +[Pi](./providers-pi.md), [Muse Code](./providers-muse.md), [fx](./providers-fx.md), and +[Mistral Vibe](./providers-vibe.md). ## Next steps diff --git a/docs/user/managed-policy.md b/docs/user/managed-policy.md index c18bb6aa93939..4bbd107190571 100644 --- a/docs/user/managed-policy.md +++ b/docs/user/managed-policy.md @@ -256,7 +256,7 @@ so restart T3 Code after that. `providers.allowedDrivers` lists the providers that may run, by ID: `codex` (Codex), `claudeAgent` (Claude), `cursor` (Cursor), `grok` (Grok), `opencode` (OpenCode), `antigravity` -(Antigravity), `fx` (fx), `kiro` (Kiro), `pi` (Pi), `muse` (Muse Code), and `acpRegistry` (ACP +(Antigravity), `fx` (fx), `kiro` (Kiro), `vibe` (Mistral Vibe), `pi` (Pi), `muse` (Muse Code), and `acpRegistry` (ACP Registry). `t3 policy check` rejects an ID this version does not know. Threads on other providers stay, marked unavailable, and users can still turn those providers off. diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 796e3f316122d..3e6867b5ef647 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -57,4 +57,7 @@ approvals for actions that support them. Kiro offers only **Supervised** and **Full access**. In **Supervised** Kiro also asks you to review a turn's file changes. See [Kiro permissions](./providers-kiro.md#permission-modes). +Mistral Vibe offers no **Auto**. Each other mode runs the matching Vibe agent. See +[Mistral Vibe permissions](./providers-vibe.md#permission-modes). + See the [provider guides](./install.md#providers) for setup and provider-specific limits. diff --git a/docs/user/providers-vibe.md b/docs/user/providers-vibe.md new file mode 100644 index 0000000000000..bd5a32159910a --- /dev/null +++ b/docs/user/providers-vibe.md @@ -0,0 +1,36 @@ +# Mistral Vibe + +T3 Code runs [Mistral Vibe](https://github.com/mistralai/mistral-vibe) as an agent through its ACP +server, `vibe-acp`, using your own Mistral account and Vibe configuration. + +## Set Up Mistral Vibe + +1. Install Vibe on the machine running the T3 Code server: + `curl -LsSf https://mistral.ai/vibe/install.sh | bash`, or `uv tool install mistral-vibe`. +2. Run `vibe --setup` once in a terminal. You can instead set `MISTRAL_API_KEY` in the Mistral Vibe + provider's **Mistral API key** field. +3. Open **Settings → Providers**, add **Mistral Vibe**, and enable it. + +If `vibe-acp` is not on the server's `PATH`, set its **Binary path**. The installer usually puts it +in `~/.local/bin`. + +## Models + +The model picker lists the models Vibe offers once it is signed in. **Default** keeps the model +Vibe itself would start with. That model shows a **Thinking** setting with the levels Vibe offers +for it; other models run at Vibe's own thinking setting. + +After changing your Vibe configuration or account, refresh provider status in **Settings → +Providers** to reload the model list. + +## Permission Modes + +The permission mode picks the Vibe agent a thread runs as: + +- **Supervised** runs Vibe's `ask` agent. Commands and file changes wait for your approval; reads + and searches run without asking. +- **Auto-accept edits** runs `accept-edits`: Vibe applies file edits itself and asks before + other tools. +- **Full access** runs `auto-approve`, which runs every tool without asking. + +Vibe offers no **Auto** mode in T3 Code. diff --git a/package.json b/package.json index d7cd927bc5613..55401a3f2bf14 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "report:renders": "node scripts/render-metrics.ts", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-fx --workspace packages/provider-grok --workspace packages/provider-kiro --workspace packages/secret-provider-core --workspace packages/secret-provider-definitions --workspace packages/secret-provider-onepassword --workspace packages/source-control-core --workspace packages/source-control-definitions --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-fx --workspace packages/provider-grok --workspace packages/provider-kiro --workspace packages/provider-vibe --workspace packages/secret-provider-core --workspace packages/secret-provider-definitions --workspace packages/secret-provider-onepassword --workspace packages/source-control-core --workspace packages/source-control-definitions --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "mobile:install": "node scripts/mobile-native-client.ts ensure", diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 15d9bc6696281..4b75ace308886 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -548,6 +548,7 @@ "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", + "@t3tools/provider-vibe": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/source-control-core": "workspace:*", "@t3tools/source-control-definitions": "workspace:*", diff --git a/packages/client-runtime/src/providerClients.ts b/packages/client-runtime/src/providerClients.ts index e23200653b963..e133dcf009909 100644 --- a/packages/client-runtime/src/providerClients.ts +++ b/packages/client-runtime/src/providerClients.ts @@ -15,6 +15,7 @@ import { kiroClient } from "@t3tools/provider-kiro/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; +import { vibeClient } from "@t3tools/provider-vibe/client"; export const providerClients = makeProviderClientRegistry([ { @@ -43,6 +44,7 @@ export const providerClients = makeProviderClientRegistry([ settingsSchema: AntigravitySettings, }, kiroClient, + vibeClient, museClient, piClient, acpRegistryClient, diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 1a2a519e1abc7..ab1176b82c359 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -120,6 +120,7 @@ const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const FX_DRIVER_KIND = ProviderDriverKind.make("fx"); const KIRO_DRIVER_KIND = ProviderDriverKind.make("kiro"); +const VIBE_DRIVER_KIND = ProviderDriverKind.make("vibe"); const MUSE_DRIVER_KIND = ProviderDriverKind.make("muse"); const PI_DRIVER_KIND = ProviderDriverKind.make("pi"); const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); @@ -150,6 +151,8 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [GROK_DRIVER_KIND]: "Grok", [FX_DRIVER_KIND]: "fx", [KIRO_DRIVER_KIND]: "Kiro", + [VIBE_DRIVER_KIND]: "Mistral Vibe", [MUSE_DRIVER_KIND]: "Muse Code", [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", [PI_DRIVER_KIND]: "Pi", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 798e076495a95..4f480c0684ca6 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1398,7 +1398,7 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un // Must match each driver's enabled decoding default. const DEFAULT_DISABLED_PROVIDER_DRIVERS: ReadonlySet = new Set( - ["cursor", "grok", "fx", "kiro", "muse", "pi", "opencode", "antigravity"].map((driver) => + ["cursor", "grok", "fx", "kiro", "vibe", "muse", "pi", "opencode", "antigravity"].map((driver) => ProviderDriverKind.make(driver), ), ); diff --git a/packages/provider-vibe/package.json b/packages/provider-vibe/package.json new file mode 100644 index 0000000000000..3d1fce71192aa --- /dev/null +++ b/packages/provider-vibe/package.json @@ -0,0 +1,36 @@ +{ + "name": "@t3tools/provider-vibe", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + } + }, + "scripts": { + "typecheck": "effect-tsgo tsrs --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-acp": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "effect-acp": "workspace:*", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-vibe/src/client.ts b/packages/provider-vibe/src/client.ts new file mode 100644 index 0000000000000..ba0e9e845f670 --- /dev/null +++ b/packages/provider-vibe/src/client.ts @@ -0,0 +1,43 @@ +/** + * Mistral Vibe's client definition: settings form, label, and glyph. + * Browser- and React Native-safe. + * + * @module provider-vibe/client + */ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { defineProviderClient } from "@t3tools/provider-core/client"; + +import { VibeSettings } from "./settings.ts"; + +export const vibeClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("vibe"), + label: "Mistral Vibe", + acpRegistryAgentIds: ["mistral-vibe"], + settingsSchema: VibeSettings, + environmentFields: [ + { + name: "MISTRAL_API_KEY", + label: "Mistral API key", + description: "Optional. Signs in without `vibe --setup`.", + placeholder: "Paste API key", + sensitive: true, + }, + ], + // mistralai/mistral-vibe distribution/zed/icons/mistral_vibe.svg (Apache-2.0). + icon: { + viewBox: "0 0 28 28", + fill: { light: "#000000", dark: "#FFFFFF" }, + paths: [ + { d: "M8.8147 5.35803H5.35791V8.46914H8.8147V5.35803Z" }, + { d: "M22.6419 5.35803H19.1851V8.46914H22.6419V5.35803Z" }, + { d: "M15.7283 15.7284H12.2715V18.8395H15.7283V15.7284Z" }, + { d: "M8.8147 15.7284H5.35791V18.8395H8.8147V15.7284Z" }, + { d: "M22.6419 15.7284H19.1851V18.8395H22.6419V15.7284Z" }, + { d: "M12.2715 8.81482H5.35791V11.9259H12.2715V8.81482Z" }, + { d: "M12.2718 19.1852H1.90137V22.2963H12.2718V19.1852Z" }, + { d: "M26.0989 19.1852H15.7285V22.2963H26.0989V19.1852Z" }, + { d: "M22.6419 12.2716H5.35791V15.3827H22.6419V12.2716Z" }, + { d: "M22.6421 8.81482H15.7285V11.9259H22.6421V8.81482Z" }, + ], + }, +}); diff --git a/packages/provider-vibe/src/server.ts b/packages/provider-vibe/src/server.ts new file mode 100644 index 0000000000000..b5f8a9e00a85a --- /dev/null +++ b/packages/provider-vibe/src/server.ts @@ -0,0 +1,6 @@ +/** + * Mistral Vibe's server entry: the driver the server registers. + * + * @module provider-vibe/server + */ +export { VibeDriver, type VibeDriverEnv } from "./server/driver.ts"; diff --git a/packages/provider-vibe/src/server/acpSupport.test.ts b/packages/provider-vibe/src/server/acpSupport.test.ts new file mode 100644 index 0000000000000..f3410b85896fc --- /dev/null +++ b/packages/provider-vibe/src/server/acpSupport.test.ts @@ -0,0 +1,82 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { RuntimeMode } from "@t3tools/contracts"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; + +import { + vibeCustomModelSlugs, + vibePromptFailure, + vibeRuntimeModeForSessionMode, + vibeSessionModeForPolicy, +} from "./acpSupport.ts"; + +const policy = (runtimeMode: RuntimeMode, overrides: Record = {}) => + ProviderAdapterV2RuntimePolicy.make({ + runtimeMode, + interactionMode: "default", + cwd: "/work", + ...overrides, + }); + +describe("Vibe agents for T3 runtime modes", () => { + it("runs each runtime mode as its Vibe agent", () => { + const cases: ReadonlyArray = [ + ["approval-required", "ask", "approval-required"], + ["auto-accept-edits", "accept-edits", "auto-accept-edits"], + ["full-access", "auto-approve", "full-access"], + ["auto", "ask", "approval-required"], + ]; + for (const [runtimeMode, agent, granted] of cases) { + assert.equal(vibeSessionModeForPolicy(policy(runtimeMode)), agent, runtimeMode); + assert.equal(vibeRuntimeModeForSessionMode(agent), granted, agent); + } + assert.isUndefined(vibeRuntimeModeForSessionMode("smart-approve")); + }); + + // Vibe's own permissive agents would approve calls before T3's policy sees them. + it("keeps an explicit approval or sandbox policy on ask", () => { + assert.equal( + vibeSessionModeForPolicy(policy("full-access", { approvalPolicy: "on-request" })), + "ask", + ); + assert.equal( + vibeSessionModeForPolicy(policy("full-access", { sandboxPolicy: { type: "readOnly" } })), + "ask", + ); + }); +}); + +describe("vibePromptFailure", () => { + it("classifies Vibe's rate limit as a usage limit", () => { + const limit = new EffectAcpErrors.AcpRequestError({ + code: -31001, + errorMessage: "Rate limit exceeded for mistral (model: mistral-vibe-cli-latest).", + }); + assert.deepEqual(vibePromptFailure(limit), { + class: "usage_limit", + message: limit.errorMessage, + code: "-31001", + retryable: null, + }); + assert.equal( + vibePromptFailure( + new EffectAcpErrors.AcpRequestError({ code: -32603, errorMessage: "Internal error" }), + ).class, + "provider_error", + ); + assert.equal(vibePromptFailure(new Error("private defect")).message, "Provider turn failed."); + }); +}); + +describe("vibeCustomModelSlugs", () => { + // `default` is T3's keep-the-session-model choice, never a model id Vibe knows. + it("sends custom models to Vibe except the reserved default", () => { + assert.deepEqual( + vibeCustomModelSlugs({ + customModels: ["devstral-medium", "default", { slug: "codestral", name: "Codestral" }], + }), + ["devstral-medium", "codestral"], + ); + }); +}); diff --git a/packages/provider-vibe/src/server/acpSupport.ts b/packages/provider-vibe/src/server/acpSupport.ts new file mode 100644 index 0000000000000..20dea35aafd16 --- /dev/null +++ b/packages/provider-vibe/src/server/acpSupport.ts @@ -0,0 +1,138 @@ +import { ProviderDriverKind, type RuntimeMode } from "@t3tools/contracts"; +import type { VibeSettings } from "../settings.ts"; +import { readCustomModelEntries } from "@t3tools/shared/model"; +import type * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; +import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import type { AcpAdapterV2ExtensionContext } from "@t3tools/provider-acp/server/adapter"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; + +export const VIBE_DRIVER_KIND = ProviderDriverKind.make("vibe"); +/** T3's slug for "Default": the session keeps Vibe's own active model. */ +export const VIBE_DEFAULT_MODEL_SLUG = "default"; +export const VIBE_MODE_CONFIG_ID = "mode"; + +/** Custom model slugs to send to Vibe; `default` stays T3's keep-the-session-model choice. */ +export function vibeCustomModelSlugs(settings: Pick) { + return readCustomModelEntries(settings.customModels) + .map((entry) => entry.slug) + .filter((slug) => slug !== VIBE_DEFAULT_MODEL_SLUG); +} + +/** + * Vibe's agents are permission profiles. Smart Approve, the match for Auto, + * ships hidden behind Vibe configuration, so Auto is not offered. + */ +export const VIBE_SUPPORTED_RUNTIME_MODES = [ + "approval-required", + "auto-accept-edits", + "full-access", +] as const satisfies ReadonlyArray; + +const VIBE_MODE_BY_RUNTIME_MODE = { + "approval-required": "ask", + "auto-accept-edits": "accept-edits", + "full-access": "auto-approve", +} as const satisfies Record<(typeof VIBE_SUPPORTED_RUNTIME_MODES)[number], string>; + +/** + * An explicit approval or sandbox policy keeps Vibe on `ask`, so every tool + * call reaches T3's policy check instead of Vibe approving it itself. + */ +export function vibeSessionModeForPolicy( + policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, +): string { + if (policy.approvalPolicy !== undefined || policy.sandboxPolicy !== undefined) return "ask"; + const mode = VIBE_SUPPORTED_RUNTIME_MODES.find((supported) => supported === policy.runtimeMode); + return mode === undefined ? "ask" : VIBE_MODE_BY_RUNTIME_MODE[mode]; +} + +export function vibeRuntimeModeForSessionMode(modeId: string): RuntimeMode | undefined { + return VIBE_SUPPORTED_RUNTIME_MODES.find((mode) => VIBE_MODE_BY_RUNTIME_MODE[mode] === modeId); +} + +/** Vibe answers a cancelled prompt with `stopReason: cancelled`. */ +const VIBE_ACP_CANCEL_BEHAVIOR = "wait-for-prompt" as const; + +// Mistral Vibe's application rate-limit code, outside ACP. +const VIBE_RATE_LIMITED = -31001; +const isAcpRequestError = Schema.is(EffectAcpErrors.AcpRequestError); + +const VibeSessionRetrying = Schema.Struct({ + sessionId: Schema.String, + category: Schema.Literals(["rate_limited", "server_error", "timed_out", "connection", "unknown"]), + detail: Schema.String, +}); + +/** Vibe reports its SDK backoff through this ACP extension. */ +export function registerVibeAcpExtensions(context: AcpAdapterV2ExtensionContext) { + return context.runtime.handleExtNotification("_session/retrying", VibeSessionRetrying, (notice) => + context.reportProviderRetry({ + sessionId: notice.sessionId, + failure: makeProviderFailure({ + message: notice.detail, + class: + notice.category === "rate_limited" + ? "usage_limit" + : notice.category === "unknown" + ? "provider_error" + : "transport_error", + retryable: true, + }), + }), + ); +} + +export function vibePromptFailure(cause: unknown) { + return makeProviderFailure({ + cause, + ...(isAcpRequestError(cause) + ? { + message: cause.errorMessage, + code: String(cause.code), + class: + cause.code === VIBE_RATE_LIMITED + ? ("usage_limit" as const) + : ("provider_error" as const), + } + : { class: "provider_error" as const }), + }); +} + +interface VibeAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "spawn" | "cancelBehavior" +> { + readonly settings: Pick; + readonly environment?: NodeJS.ProcessEnv; +} + +export const makeVibeAcpRuntime = Effect.fn("makeVibeAcpRuntime")(function* ( + input: VibeAcpRuntimeInput, +): Effect.fn.Return< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | Scope.Scope +> { + const { settings, environment, ...runtimeOptions } = input; + const context = yield* Layer.build( + AcpSessionRuntime.layer({ + ...runtimeOptions, + spawn: { + command: settings.binaryPath || "vibe-acp", + args: [], + cwd: input.cwd, + env: { ...environment }, + }, + cancelBehavior: VIBE_ACP_CANCEL_BEHAVIOR, + }), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(Effect.provide(context)); +}); diff --git a/packages/provider-vibe/src/server/adapter.ts b/packages/provider-vibe/src/server/adapter.ts new file mode 100644 index 0000000000000..5b967fb6c191f --- /dev/null +++ b/packages/provider-vibe/src/server/adapter.ts @@ -0,0 +1,122 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime"; +import { VibeSettings } from "../settings.ts"; +import type * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import type * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import type * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import type * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "@t3tools/provider-core/server/adapterDriver"; +import { + AcpProviderCapabilitiesV2, + makeAcpAdapterV2, + type AcpAdapterV2RuntimeInput, +} from "@t3tools/provider-acp/server/adapter"; +import { + makeVibeAcpRuntime, + registerVibeAcpExtensions, + VIBE_DRIVER_KIND, + vibeCustomModelSlugs, + vibePromptFailure, + vibeRuntimeModeForSessionMode, + vibeSessionModeForPolicy, +} from "./acpSupport.ts"; + +const DEFAULT_VIBE_SETTINGS = Schema.decodeSync(VibeSettings)({}); + +interface VibeAdapterV2Options { + readonly instanceId: Parameters[0]["instanceId"]; + readonly settings: VibeSettings; + readonly environment: NodeJS.ProcessEnv; + readonly selfInvocation: SelfInvocation; + readonly nativeLogging?: Parameters[0]["nativeLogging"]; +} + +const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* (options: VibeAdapterV2Options) { + const customModels = vibeCustomModelSlugs(options.settings); + return yield* makeAcpAdapterV2({ + instanceId: options.instanceId, + selfInvocation: options.selfInvocation, + flavor: { + driver: VIBE_DRIVER_KIND, + runtimeHarness: "vibe", + capabilities: AcpProviderCapabilitiesV2, + customModels, + makeRuntime: ({ + runtimePolicy: _runtimePolicy, + processEnvironment, + ...input + }: AcpAdapterV2RuntimeInput) => + makeVibeAcpRuntime({ + ...input, + settings: options.settings, + customModels, + environment: { ...options.environment, ...processEnvironment }, + }), + sessionModeForPolicy: vibeSessionModeForPolicy, + runtimeModeForSessionMode: vibeRuntimeModeForSessionMode, + registerExtensions: registerVibeAcpExtensions, + promptFailure: vibePromptFailure, + }, + ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), + }); +}); + +export type VibeAdapterV2DriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | SecureRandom.SecureRandom + | FileSystem.FileSystem + | IdAllocator.IdAllocatorV2 + | Path.Path + | McpProviderSessions.McpProviderSessions + | ProviderEventLoggers.ProviderEventLoggers + | ProviderHost.ProviderHost; + +export const VibeAdapterV2Driver: ProviderAdapterDriver = { + driverKind: VIBE_DRIVER_KIND, + configSchema: VibeSettings, + defaultConfig: (): VibeSettings => DEFAULT_VIBE_SETTINGS, + create: Effect.fn("VibeAdapterV2Driver.create")( + function* (input: ProviderAdapterDriverCreateInput) { + const hostEnvironment = yield* HostProcess.Environment; + const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; + const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); + return yield* makeVibeAdapterV2({ + instanceId: input.instanceId, + settings: { ...input.config, enabled: input.enabled }, + environment: yield* mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + selfInvocation: yield* resolveSelfInvocation(), + nativeLogging: (threadId) => + makeNativeLogger({ + nativeEventLogger: providerEventLoggers.native, + provider: VIBE_DRIVER_KIND, + threadId, + }), + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: VIBE_DRIVER_KIND, + instanceId: input.instanceId, + detail: "Failed to create the Mistral Vibe ACP adapter.", + cause, + }), + ), + ), + ), +}; diff --git a/packages/provider-vibe/src/server/driver.ts b/packages/provider-vibe/src/server/driver.ts new file mode 100644 index 0000000000000..809a2007cc498 --- /dev/null +++ b/packages/provider-vibe/src/server/driver.ts @@ -0,0 +1,147 @@ +import { TextGenerationError } from "@t3tools/contracts"; +import { VibeSettings } from "../settings.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import { type VibeAdapterV2DriverEnv, VibeAdapterV2Driver } from "./adapter.ts"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; +import { buildInitialVibeProviderSnapshot, checkVibeProviderStatus } from "./status.ts"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "@t3tools/provider-core/server/driver"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "@t3tools/provider-core/server/snapshotSettings"; +import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; +import { VIBE_DRIVER_KIND } from "./acpSupport.ts"; + +const decodeVibeSettings = Schema.decodeSync(VibeSettings); +// Vibe installs and updates through uv or its own installer; there is no npm +// package to read a latest version from. +const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ + provider: VIBE_DRIVER_KIND, + packageName: null, +}); + +// See `supportsTextGeneration` in status.ts. +const unsupportedTextGeneration: ProviderTextGeneration = (() => { + const unsupported = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Mistral Vibe does not provide application text generation.", + }), + ); + return { + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), + generateReviewGuide: () => unsupported("generateReviewGuide"), + }; +})(); + +export type VibeDriverEnv = VibeAdapterV2DriverEnv; + +export const VibeDriver: ProviderDriver = { + driverKind: VIBE_DRIVER_KIND, + metadata: { + displayName: "Mistral Vibe", + supportsMultipleInstances: true, + }, + configSchema: VibeSettings, + defaultConfig: (): VibeSettings => decodeVibeSettings({}), + create: Effect.fn("VibeDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* SecureRandom.SecureRandom; + const processEnv = yield* mergeProviderInstanceEnvironment( + environment, + yield* HostProcess.Environment, + ); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: VIBE_DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: VIBE_DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies VibeSettings; + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance: () => Effect.succeed(MAINTENANCE), + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialVibeProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider: checkVibeProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(SecureRandom.SecureRandom, crypto), + ), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: VIBE_DRIVER_KIND, + instanceId, + detail: "Failed to build the Mistral Vibe provider snapshot.", + cause, + }), + ), + ); + + const orchestrationAdapter = yield* VibeAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: VIBE_DRIVER_KIND, + instanceId, + detail: "Failed to build the Mistral Vibe orchestration adapter.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: VIBE_DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + orchestrationAdapter, + textGeneration: unsupportedTextGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/packages/provider-vibe/src/server/status.test.ts b/packages/provider-vibe/src/server/status.test.ts new file mode 100644 index 0000000000000..f4424ded3c590 --- /dev/null +++ b/packages/provider-vibe/src/server/status.test.ts @@ -0,0 +1,136 @@ +import { assert, describe, it } from "@effect/vitest"; +import type * as EffectAcpSchema from "effect-acp/compat"; + +import { + vibeAuthFromStatusReport, + vibeModelsFromConfigOptions, + vibeProbeStatus, + vibeRuntimeModes, +} from "./status.ts"; + +// Shaped like vibe-acp 3.0.3's `session/new` config options. +const VIBE_CONFIG_OPTIONS = [ + { + id: "mode", + name: "Session Mode", + category: "mode", + type: "select", + currentValue: "accept-edits", + options: [ + { value: "ask", name: "Ask", description: "Requires approval for tool executions" }, + { value: "accept-edits", name: "Accept Edits", description: "Auto-approves file edits only" }, + { + value: "auto-approve", + name: "Auto Approve", + description: "Auto-approves all tool executions", + }, + ], + }, + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "mistral-medium-3.5", + options: [ + { + value: "mistral-medium-3.5", + name: "Mistral Medium 3.5", + description: "mistral-vibe-cli-latest", + }, + { value: "devstral-small", name: "Devstral Small" }, + ], + }, + { + id: "thinking", + name: "Thinking", + category: "thinking", + type: "select", + currentValue: "high", + options: [ + { value: "off", name: "Off" }, + { value: "high", name: "High" }, + ], + }, +] satisfies ReadonlyArray; + +describe("vibeAuthFromStatusReport", () => { + it("reads `_auth/status`", () => { + assert.deepEqual(vibeAuthFromStatusReport({ authenticated: true, authState: "os_keyring" }), { + status: "authenticated", + }); + assert.deepEqual(vibeAuthFromStatusReport({ authenticated: false, authState: "missing" }), { + status: "unauthenticated", + }); + assert.deepEqual(vibeAuthFromStatusReport({ authState: "os_keyring" }), { status: "unknown" }); + assert.deepEqual(vibeAuthFromStatusReport(null), { status: "unknown" }); + }); +}); + +describe("vibeModelsFromConfigOptions", () => { + it("lists Default and Vibe's models, with thinking only where Vibe listed it", () => { + const models = vibeModelsFromConfigOptions(VIBE_CONFIG_OPTIONS); + assert.deepEqual( + models.map((model) => [model.slug, model.name, model.isDefault ?? false]), + [ + ["default", "Default (Mistral Medium 3.5)", true], + ["mistral-medium-3.5", "Mistral Medium 3.5", false], + ["devstral-small", "Devstral Small", false], + ], + ); + assert.deepEqual(models[0]?.aliases, ["mistral-medium-3.5"]); + assert.deepEqual( + models.map((model) => + model.capabilities?.optionDescriptors?.map((descriptor) => descriptor.id), + ), + [["thinking"], ["thinking"], []], + ); + }); + + it("offers only Default before Vibe is signed in", () => { + assert.deepEqual( + vibeModelsFromConfigOptions([]).map((model) => model.name), + ["Default"], + ); + }); +}); + +describe("vibeProbeStatus", () => { + it("keeps a confirmed sign-in when the model list fails", () => { + assert.deepEqual(vibeProbeStatus({ status: "authenticated" }, VIBE_CONFIG_OPTIONS), { + status: "ready", + }); + assert.deepEqual(vibeProbeStatus({ status: "authenticated" }, undefined), { + status: "warning", + message: "Mistral Vibe is signed in but its models could not be listed.", + }); + assert.equal(vibeProbeStatus({ status: "unauthenticated" }, undefined).status, "error"); + assert.equal(vibeProbeStatus({ status: "unknown" }, undefined).status, "warning"); + }); +}); + +const onlyAgents = (agents: ReadonlyArray) => + VIBE_CONFIG_OPTIONS.map((option) => + option.id === "mode" + ? { ...option, options: agents.map((agent) => ({ value: agent, name: agent })) } + : option, + ); + +describe("vibeRuntimeModes", () => { + it("offers only the modes whose Vibe agent the session lists", () => { + const allModes = ["approval-required", "auto-accept-edits", "full-access"] as const; + assert.deepEqual(vibeRuntimeModes(VIBE_CONFIG_OPTIONS), allModes); + assert.deepEqual(vibeRuntimeModes(undefined), allModes); + assert.deepEqual(vibeRuntimeModes(onlyAgents(["ask", "accept-edits"])), [ + "approval-required", + "auto-accept-edits", + ]); + }); + + // `enabled_agents = ["smart-approve"]` leaves no agent T3 Code can select. + it("reports a Vibe that enables none of T3's agents", () => { + const smartOnly = onlyAgents(["smart-approve"]); + assert.deepEqual(vibeRuntimeModes(smartOnly), []); + assert.equal(vibeProbeStatus({ status: "authenticated" }, smartOnly).status, "error"); + }); +}); diff --git a/packages/provider-vibe/src/server/status.ts b/packages/provider-vibe/src/server/status.ts new file mode 100644 index 0000000000000..472fe344efc38 --- /dev/null +++ b/packages/provider-vibe/src/server/status.ts @@ -0,0 +1,296 @@ +import type * as EffectAcpSchema from "effect-acp/compat"; +import { + type RuntimeMode, + type ServerProviderAuth, + type ServerProviderModel, +} from "@t3tools/contracts"; +import type { VibeSettings } from "../settings.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import type * as SecureRandom from "effect/crypto/SecureRandom"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/process/ChildProcess"; +import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import { + acpProviderOptionDescriptors, + flattenSelectChoices, +} from "@t3tools/provider-acp/server/sessionConfig"; +import { findModelConfigOption } from "@t3tools/provider-acp/server/runtimeModel"; +import { + makeVibeAcpRuntime, + VIBE_DEFAULT_MODEL_SLUG, + VIBE_DRIVER_KIND, + VIBE_MODE_CONFIG_ID, + VIBE_SUPPORTED_RUNTIME_MODES, + vibeRuntimeModeForSessionMode, +} from "./acpSupport.ts"; +import { + AUTH_PROBE_TIMEOUT_MS, + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + type ProviderProbeResult, + providerModelsFromSettings, + type ServerProviderDraft, + spawnAndCollect, +} from "@t3tools/provider-core/server/snapshotProbe"; + +const VIBE_PRESENTATION = { + displayName: "Mistral Vibe", + showInteractionModeToggle: false, +} as const; +const VERSION_PROBE_TIMEOUT_MS = 10_000; +const NO_OPTIONS = createModelCapabilities({ optionDescriptors: [] }); + +/** `_auth/status` on vibe-acp 3.0: `authenticated` is whether the active provider can be used. */ +const decodeVibeAuthStatus = Schema.decodeUnknownOption( + Schema.Struct({ authenticated: Schema.Boolean }), +); + +export function vibeAuthFromStatusReport(report: unknown): ServerProviderAuth { + const decoded = decodeVibeAuthStatus(report); + if (Option.isNone(decoded)) return { status: "unknown" }; + return { status: decoded.value.authenticated ? "authenticated" : "unauthenticated" }; +} + +/** + * Vibe's model choices from a session's config options. "Default" keeps the + * session's own model. Vibe lists thinking levels for that model only, so the + * others carry none. The mode option is left out: T3's runtime mode selects + * the Vibe agent. + */ +export function vibeModelsFromConfigOptions( + configOptions: ReadonlyArray, +): ReadonlyArray { + const currentCapabilities = createModelCapabilities({ + optionDescriptors: acpProviderOptionDescriptors({ + configOptions: configOptions.filter((option) => option.id !== VIBE_MODE_CONFIG_ID), + modeState: undefined, + }), + }); + const modelOption = findModelConfigOption(configOptions); + const choices = modelOption?.type === "select" ? flattenSelectChoices(modelOption.options) : []; + const current = modelOption?.type === "select" ? modelOption.currentValue : undefined; + const currentName = choices.find((choice) => choice.value === current)?.name; + return [ + { + slug: VIBE_DEFAULT_MODEL_SLUG, + name: currentName === undefined ? "Default" : `Default (${currentName})`, + ...(current === undefined ? {} : { aliases: [current] }), + isCustom: false, + isDefault: true, + capabilities: currentCapabilities, + }, + ...choices + .filter((choice) => choice.value !== VIBE_DEFAULT_MODEL_SLUG) + .map((choice) => ({ + slug: choice.value, + name: choice.name || choice.value, + isCustom: false, + capabilities: choice.value === current ? currentCapabilities : NO_OPTIONS, + })), + ]; +} + +/** + * The runtime modes whose Vibe agent the session lists. Vibe config can turn an + * agent off (`enable_auto_approve = false`), and selecting it then fails. + */ +export function vibeRuntimeModes( + configOptions: ReadonlyArray | undefined, +): ReadonlyArray { + const modeOption = configOptions?.find( + (option) => option.id === VIBE_MODE_CONFIG_ID && option.type === "select", + ); + if (modeOption?.type !== "select") return VIBE_SUPPORTED_RUNTIME_MODES; + const listed = flattenSelectChoices(modeOption.options).map((choice) => + vibeRuntimeModeForSessionMode(choice.value), + ); + return VIBE_SUPPORTED_RUNTIME_MODES.filter((mode) => listed.includes(mode)); +} + +const snapshot = ( + settings: VibeSettings, + checkedAt: string, + probe: ProviderProbeResult, + configOptions?: ReadonlyArray, +): ServerProviderDraft => { + const models = vibeModelsFromConfigOptions(configOptions ?? []); + return { + ...buildServerProvider({ + driver: VIBE_DRIVER_KIND, + presentation: { + ...VIBE_PRESENTATION, + supportedRuntimeModes: vibeRuntimeModes(configOptions), + }, + enabled: settings.enabled, + checkedAt, + models: providerModelsFromSettings(models, settings.customModels, NO_OPTIONS), + probe, + }), + supportsTextGeneration: false, + }; +}; + +export const buildInitialVibeProviderSnapshot = ( + settings: VibeSettings, +): Effect.Effect => + Effect.map(DateTime.now, (now) => + snapshot(settings, DateTime.formatIso(now), { + installed: settings.enabled, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: settings.enabled + ? "Checking Mistral Vibe availability..." + : "Mistral Vibe is disabled in T3 Code settings.", + }), + ); + +const runVibeVersion = Effect.fn("runVibeVersion")(function* ( + settings: VibeSettings, + environment: NodeJS.ProcessEnv, +) { + const command = settings.binaryPath || "vibe-acp"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); +}); + +/** + * `initialize` and `_auth/status` need no session. Only a signed-in Vibe gets + * a `session/new` for its model list: Vibe saves no session until a prompt, + * and signed out it refuses to open one. + */ +const probeVibeAcp = Effect.fn("VibeProvider.probeVibeAcp")(function* ( + settings: VibeSettings, + environment: NodeJS.ProcessEnv, +) { + const runtime = yield* makeVibeAcpRuntime({ + settings, + environment, + cwd: yield* HostProcess.WorkingDirectory, + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + const auth = yield* runtime.initialize().pipe( + Effect.andThen(runtime.request("_auth/status", {})), + Effect.map(vibeAuthFromStatusReport), + Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), + Effect.map(Option.getOrElse((): ServerProviderAuth => ({ status: "unknown" }))), + Effect.orElseSucceed((): ServerProviderAuth => ({ status: "unknown" })), + ); + if (auth.status !== "authenticated") return { auth, configOptions: undefined }; + const configOptions = yield* runtime.start().pipe( + Effect.map((started) => started.sessionSetupResult.configOptions ?? undefined), + Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), + Effect.map(Option.getOrUndefined), + Effect.orElseSucceed(() => undefined), + ); + return { auth, configOptions }; +}, Effect.scoped); + +/** The snapshot status for what the ACP probe learned. */ +export function vibeProbeStatus( + auth: ServerProviderAuth, + configOptions: ReadonlyArray | undefined, +): Pick { + if (auth.status === "unauthenticated") { + return { + status: "error", + message: "Mistral Vibe is not signed in. Run `vibe --setup` or set MISTRAL_API_KEY.", + }; + } + if (auth.status !== "authenticated") { + return { status: "warning", message: "Could not confirm the Mistral Vibe sign-in." }; + } + if (configOptions === undefined) { + return { + status: "warning", + message: "Mistral Vibe is signed in but its models could not be listed.", + }; + } + return vibeRuntimeModes(configOptions).length > 0 + ? { status: "ready" } + : { + status: "error", + message: + "Mistral Vibe enables none of the agents T3 Code runs: ask, accept-edits, auto-approve.", + }; +} + +export const checkVibeProviderStatus = Effect.fn("checkVibeProviderStatus")(function* ( + settings: VibeSettings, + environment: NodeJS.ProcessEnv, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + if (!settings.enabled) { + return snapshot(settings, checkedAt, { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Mistral Vibe is disabled in T3 Code settings.", + }); + } + + const versionResult = yield* runVibeVersion(settings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(versionResult) || Option.isNone(versionResult.success)) { + const missing = Result.isFailure(versionResult) && isCommandMissingCause(versionResult.failure); + return snapshot(settings, checkedAt, { + installed: !missing, + version: null, + status: "error", + auth: { status: "unknown" }, + message: missing + ? "Mistral Vibe (`vibe-acp`) is not installed or not on PATH." + : "Failed to run `vibe-acp --version`.", + }); + } + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + return snapshot(settings, checkedAt, { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Mistral Vibe is installed but `vibe-acp` failed to run.", + }); + } + + const probed = yield* probeVibeAcp(settings, environment).pipe( + Effect.orElseSucceed(() => ({ + auth: { status: "unknown" } satisfies ServerProviderAuth, + configOptions: undefined, + })), + ); + return snapshot( + settings, + checkedAt, + { + installed: true, + version, + auth: probed.auth, + ...vibeProbeStatus(probed.auth, probed.configOptions), + }, + probed.configOptions, + ); +}); diff --git a/packages/provider-vibe/src/settings.ts b/packages/provider-vibe/src/settings.ts new file mode 100644 index 0000000000000..452ef7ee74cdd --- /dev/null +++ b/packages/provider-vibe/src/settings.ts @@ -0,0 +1,37 @@ +/** + * Mistral Vibe instance settings. Shared by the server driver and the client + * settings form, so it holds only browser-safe schema code. + * + * @module provider-vibe/settings + */ +import { + CustomModelSetting, + makeBinaryPathSetting, + makeProviderSettingsSchema, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const VibeSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("vibe-acp").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to Mistral Vibe's ACP server, `vibe-acp`.", + providerSettingsForm: { placeholder: "vibe-acp", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type VibeSettings = typeof VibeSettings.Type; diff --git a/packages/provider-vibe/tsconfig.json b/packages/provider-vibe/tsconfig.json new file mode 100644 index 0000000000000..374bac55202dd --- /dev/null +++ b/packages/provider-vibe/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71ad239cc0d3a..85abc20ef0a2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -873,6 +873,9 @@ importers: '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi + '@t3tools/provider-vibe': + specifier: workspace:* + version: link:../../packages/provider-vibe '@t3tools/source-control-azure-devops': specifier: workspace:* version: link:../../packages/source-control-azure-devops @@ -1506,6 +1509,9 @@ importers: '@t3tools/provider-pi': specifier: workspace:* version: link:../provider-pi + '@t3tools/provider-vibe': + specifier: workspace:* + version: link:../provider-vibe '@t3tools/shared': specifier: workspace:* version: link:../shared @@ -1963,6 +1969,37 @@ importers: specifier: 24.12.4 version: 24.12.4 + packages/provider-vibe: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-acp': + specifier: workspace:* + version: link:../provider-acp + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.1.2 + version: 4.1.2 + devDependencies: + '@effect/vitest': + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + effect-acp: + specifier: workspace:* + version: link:../effect-acp + vite-plus: + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/secret-provider-core: dependencies: '@t3tools/contracts': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index a586d67ef6035..c62206b81c83f 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -40,6 +40,7 @@ const workspaceFiles = [ "packages/provider-kiro/package.json", "packages/provider-muse/package.json", "packages/provider-opencode/package.json", + "packages/provider-vibe/package.json", "packages/secret-provider-core/package.json", "packages/secret-provider-definitions/package.json", "packages/secret-provider-onepassword/package.json",