From bae3609561204fb73b0296030729a806b093dc53 Mon Sep 17 00:00:00 2001
From: iliya
@@ -162,22 +167,211 @@ interface InstalledBannerProps {
cliStatusLoading: boolean;
cliStatusError: string | null;
isBusy: boolean;
+ multimodelEnabled: boolean;
+ multimodelBusy: boolean;
onInstall: () => void;
onRefresh: () => void;
+ onMultimodelToggle: (enabled: boolean) => void;
+ onProviderLogin: (providerId: CliProviderId) => void;
+ onProviderLogout: (providerId: CliProviderId) => void;
variant: BannerVariant;
}
+function getProviderLabel(providerId: CliProviderId): string {
+ switch (providerId) {
+ case 'anthropic':
+ return 'Anthropic';
+ case 'codex':
+ return 'Codex';
+ case 'gemini':
+ return 'Gemini';
+ }
+}
+
+function getProviderTerminalCommand(providerId: CliProviderId): {
+ args: string[];
+ env?: Record
Failed to check for updates. Check your network connection and try again.
+ The configured free-code runtime was not found. +
+ )} ); } // Installed but not logged in — yellow warning banner - if (cliStatus.installed && !cliStatus.authLoggedIn) { + if (cliStatus.installed && cliStatus.flavor !== 'free-code' && !cliStatus.authLoggedIn) { if (isVerifyingAuth) { return (- Claude CLI is installed but you are not authenticated. Login is required for team - provisioning and AI features. + {cliStatus.displayName} is installed but you are not authenticated. Login is + required for team provisioning and AI features.
- claude auth status
+ {cliStatus.showBinaryPath && cliStatus.binaryPath
+ ? `"${cliStatus.binaryPath}" auth status`
+ : 'your configured CLI auth status command'}
{' '}
— check if it shows "Logged in"
- claude auth logout
+ {cliStatus.showBinaryPath && cliStatus.binaryPath
+ ? `"${cliStatus.binaryPath}" auth logout`
+ : 'the runtime logout command'}
{' '}
then{' '}
- claude auth login
+ {cliStatus.showBinaryPath && cliStatus.binaryPath
+ ? `"${cliStatus.binaryPath}" auth login`
+ : 'the runtime login command'}
{' '}
again
- claude
- {' '}
- in your terminal is the same binary the app uses
- {cliStatus.binaryPath && (
+ Make sure the CLI in your terminal is the same runtime the app uses
+ {cliStatus.showBinaryPath && cliStatus.binaryPath && (
:{' '}
@@ -682,7 +1090,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
{showLoginTerminal && cliStatus.binaryPath && (
{
@@ -719,14 +1127,45 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
// Installed — show version, path, update info
return (
-
+ <>
+ void handleMultimodelToggle(enabled)}
+ onProviderLogin={handleProviderLogin}
+ onProviderLogout={handleProviderLogout}
+ variant={variant}
+ />
+ {providerTerminal && cliStatus.binaryPath && (
+ {
+ setProviderTerminal(null);
+ recheckAuthState();
+ }}
+ onExit={() => {
+ recheckAuthState();
+ }}
+ autoCloseOnSuccessMs={3000}
+ successMessage={
+ providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out'
+ }
+ failureMessage={
+ providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed'
+ }
+ />
+ )}
+ >
);
};
diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts
index bc6b84f8..0ab735d2 100644
--- a/src/renderer/components/settings/hooks/useSettingsConfig.ts
+++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts
@@ -29,6 +29,7 @@ export interface SafeConfig {
showDockIcon: boolean;
theme: 'dark' | 'light' | 'system';
defaultTab: 'dashboard' | 'last-session';
+ multimodelEnabled: boolean;
claudeRootPath: string | null;
agentLanguage: string;
autoExpandAIGroups: boolean;
@@ -169,6 +170,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
showDockIcon: displayConfig?.general?.showDockIcon ?? true,
theme: displayConfig?.general?.theme ?? 'dark',
defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard',
+ multimodelEnabled: displayConfig?.general?.multimodelEnabled ?? true,
claudeRootPath: displayConfig?.general?.claudeRootPath ?? null,
agentLanguage: displayConfig?.general?.agentLanguage ?? 'system',
autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false,
diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts
index 76af24e2..18e964e5 100644
--- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts
+++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts
@@ -314,6 +314,7 @@ export function useSettingsHandlers({
showDockIcon: true,
theme: 'dark',
defaultTab: 'dashboard',
+ multimodelEnabled: true,
claudeRootPath: null,
agentLanguage: 'system',
autoExpandAIGroups: false,
diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx
index c351c371..8c00fabe 100644
--- a/src/renderer/components/settings/sections/CliStatusSection.tsx
+++ b/src/renderer/components/settings/sections/CliStatusSection.tsx
@@ -5,16 +5,22 @@
* Shows detection status, version info, download progress, and error states.
*/
-import { useCallback, useEffect, useMemo } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import { isElectronMode } from '@renderer/api';
+import { confirm } from '@renderer/components/common/ConfirmDialog';
+import { SettingsToggle } from '@renderer/components/settings/components';
+import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
+import { useStore } from '@renderer/store';
import { formatBytes } from '@renderer/utils/formatters';
import {
AlertTriangle,
CheckCircle,
Download,
Loader2,
+ LogIn,
+ LogOut,
Puzzle,
RefreshCw,
Terminal,
@@ -22,8 +28,132 @@ import {
import { SettingsSectionHeader } from '../components';
+import type { CliInstallationStatus, CliProviderId } from '@shared/types';
+
+function formatModelBadgeLabel(providerId: CliProviderId, model: string): string {
+ if (providerId === 'anthropic') {
+ return model.replace(/^claude-/, '');
+ }
+ if (providerId === 'codex') {
+ return model.replace(/^gpt-/, '');
+ }
+ if (providerId === 'gemini') {
+ return model.replace(/^gemini-/, '');
+ }
+ return model;
+}
+
+function ModelBadges({
+ providerId,
+ models,
+}: {
+ providerId: CliProviderId;
+ models: string[];
+}): React.JSX.Element {
+ return (
+
+ {models.map((model) => (
+
+ {formatModelBadgeLabel(providerId, model)}
+
+ ))}
+
+ );
+}
+
+function getProviderLabel(providerId: CliProviderId): string {
+ switch (providerId) {
+ case 'anthropic':
+ return 'Anthropic';
+ case 'codex':
+ return 'Codex';
+ case 'gemini':
+ return 'Gemini';
+ }
+}
+
+function getProviderTerminalCommand(providerId: CliProviderId): {
+ args: string[];
+ env?: Record;
+} {
+ if (providerId === 'gemini') {
+ return {
+ args: ['login'],
+ env: { CLAUDE_CODE_USE_GEMINI: '1' },
+ };
+ }
+
+ return {
+ args: ['auth', 'login', '--provider', providerId],
+ };
+}
+
+function getProviderTerminalLogoutCommand(providerId: CliProviderId): {
+ args: string[];
+ env?: Record;
+} {
+ if (providerId === 'gemini') {
+ return {
+ args: ['logout'],
+ env: { CLAUDE_CODE_USE_GEMINI: '1' },
+ };
+ }
+
+ return {
+ args: ['auth', 'logout', '--provider', providerId],
+ };
+}
+
+function createLoadingMultimodelStatus(): CliInstallationStatus {
+ const providers: Array<{ providerId: CliProviderId; displayName: string }> = [
+ { providerId: 'anthropic', displayName: 'Anthropic' },
+ { providerId: 'codex', displayName: 'Codex' },
+ { providerId: 'gemini', displayName: 'Gemini' },
+ ];
+
+ return {
+ flavor: 'free-code',
+ displayName: 'free-code-gemini-research',
+ supportsSelfUpdate: false,
+ showVersionDetails: false,
+ showBinaryPath: false,
+ installed: true,
+ installedVersion: null,
+ binaryPath: null,
+ latestVersion: null,
+ updateAvailable: false,
+ authLoggedIn: false,
+ authMethod: null,
+ providers: providers.map((provider) => ({
+ ...provider,
+ supported: false,
+ authenticated: false,
+ authMethod: null,
+ verificationState: 'unknown' as const,
+ statusMessage: 'Checking...',
+ models: [],
+ canLoginFromUi: true,
+ capabilities: {
+ teamLaunch: false,
+ oneShot: false,
+ },
+ backend: null,
+ })),
+ };
+}
+
export const CliStatusSection = (): React.JSX.Element | null => {
const isElectron = useMemo(() => isElectronMode(), []);
+ const appConfig = useStore((s) => s.appConfig);
+ const updateConfig = useStore((s) => s.updateConfig);
const {
cliStatus,
installerState,
@@ -36,7 +166,18 @@ export const CliStatusSection = (): React.JSX.Element | null => {
installCli,
isBusy,
cliStatusLoading,
+ invalidateCliStatus,
} = useCliInstaller();
+ const [providerTerminal, setProviderTerminal] = useState<{
+ providerId: CliProviderId;
+ action: 'login' | 'logout';
+ } | null>(null);
+ const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
+ const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
+ const effectiveCliStatus =
+ !cliStatus && cliStatusLoading && multimodelEnabled
+ ? createLoadingMultimodelStatus()
+ : cliStatus;
useEffect(() => {
if (isElectron) {
@@ -52,38 +193,118 @@ export const CliStatusSection = (): React.JSX.Element | null => {
void fetchCliStatus();
}, [fetchCliStatus]);
+ const handleProviderLogout = useCallback(async (providerId: CliProviderId) => {
+ const confirmed = await confirm({
+ title: `Logout from ${getProviderLabel(providerId)}?`,
+ message: 'This will remove the current provider session from the local Claude CLI runtime.',
+ confirmLabel: 'Logout',
+ cancelLabel: 'Cancel',
+ variant: 'danger',
+ });
+
+ if (!confirmed) {
+ return;
+ }
+
+ setProviderTerminal({
+ providerId,
+ action: 'logout',
+ });
+ }, []);
+
+ const recheckStatus = useCallback(() => {
+ void (async () => {
+ await invalidateCliStatus();
+ await fetchCliStatus();
+ })();
+ }, [fetchCliStatus, invalidateCliStatus]);
+
+ const handleMultimodelToggle = useCallback(
+ async (enabled: boolean) => {
+ setIsSwitchingFlavor(true);
+ try {
+ await updateConfig('general', { multimodelEnabled: enabled });
+ await invalidateCliStatus();
+ await fetchCliStatus();
+ } finally {
+ setIsSwitchingFlavor(false);
+ }
+ },
+ [fetchCliStatus, invalidateCliStatus, updateConfig]
+ );
+
if (!isElectron) return null;
+ const runtimeLabel =
+ effectiveCliStatus?.flavor === 'free-code'
+ ? null
+ : effectiveCliStatus &&
+ effectiveCliStatus.showVersionDetails &&
+ effectiveCliStatus.installedVersion
+ ? `${effectiveCliStatus.displayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}`
+ : (effectiveCliStatus?.displayName ?? 'Claude CLI');
+
+ const providerTerminalCommand = providerTerminal
+ ? providerTerminal.action === 'login'
+ ? getProviderTerminalCommand(providerTerminal.providerId)
+ : getProviderTerminalLogoutCommand(providerTerminal.providerId)
+ : null;
+
return (
-
+
{/* Loading status */}
- {!cliStatus && installerState === 'idle' && (
+ {!effectiveCliStatus && installerState === 'idle' && (
- Checking CLI...
+ Checking AI Providers...
)}
{/* Status display */}
- {cliStatus && installerState === 'idle' && (
+ {effectiveCliStatus && installerState === 'idle' && (
- {cliStatus.installed ? (
+ {effectiveCliStatus.installed ? (
-
- Claude CLI v{cliStatus.installedVersion ?? 'unknown'}
-
+ {runtimeLabel && (
+ {runtimeLabel}
+ )}
+
+
+ Multimodel
+
+ {multimodelEnabled && (
+
+ Beta
+
+ )}
+ void handleMultimodelToggle(value)}
+ disabled={isBusy || cliStatusLoading || isSwitchingFlavor}
+ />
+
{/* Inline action buttons */}
- {cliStatus.updateAvailable ? (
+ {effectiveCliStatus.supportsSelfUpdate && effectiveCliStatus.updateAvailable ? (
- ) : (
+ ) : effectiveCliStatus.supportsSelfUpdate ? (
- )}
+ ) : null}
{/* Extensions button — right-aligned */}
- {cliStatus.binaryPath && (
+ {effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && (
- {cliStatus.binaryPath}
+ {effectiveCliStatus.binaryPath}
)}
- {cliStatus.updateAvailable && cliStatus.latestVersion && (
-
-
- v{cliStatus.installedVersion} → v{cliStatus.latestVersion}
-
+ {effectiveCliStatus.supportsSelfUpdate &&
+ effectiveCliStatus.updateAvailable &&
+ effectiveCliStatus.latestVersion && (
+
+
+ v{effectiveCliStatus.installedVersion} → v
+ {effectiveCliStatus.latestVersion}
+
+
+ )}
+ {effectiveCliStatus.providers.length > 0 && (
+
+ {effectiveCliStatus.providers.map((provider) => (
+
+
+
+
+ {provider.displayName}
+
+
+ {provider.authenticated
+ ? provider.authMethod
+ ? `Authenticated via ${provider.authMethod}`
+ : 'Authenticated'
+ : provider.statusMessage || 'Not connected'}
+
+
+
+ {provider.backend?.label && (
+ Backend: {provider.backend.label}
+ )}
+ {provider.models.length === 0 && (
+ Models unavailable for this runtime build
+ )}
+
+
+
+ {provider.authenticated ? (
+
+ ) : provider.canLoginFromUi ? (
+
+ ) : null}
+
+ {provider.models.length > 0 && (
+
+
+
+ )}
+
+ ))}
)}
@@ -158,7 +475,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
)}
{/* Install button (CLI not installed) */}
- {!cliStatus.installed && (
+ {!effectiveCliStatus.installed && effectiveCliStatus.supportsSelfUpdate && (
)}
+ {!effectiveCliStatus.installed && !effectiveCliStatus.supportsSelfUpdate && (
+
+ The configured free-code runtime was not found.
+
+ )}
)}
@@ -270,6 +592,30 @@ export const CliStatusSection = (): React.JSX.Element | null => {
)}
+ {providerTerminal && cliStatus?.binaryPath && (
+ {
+ setProviderTerminal(null);
+ recheckStatus();
+ }}
+ onExit={() => {
+ recheckStatus();
+ }}
+ autoCloseOnSuccessMs={3000}
+ successMessage={
+ providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out'
+ }
+ failureMessage={
+ providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed'
+ }
+ />
+ )}
);
};
diff --git a/src/renderer/components/terminal/EmbeddedTerminal.tsx b/src/renderer/components/terminal/EmbeddedTerminal.tsx
index 895cf062..78acc77d 100644
--- a/src/renderer/components/terminal/EmbeddedTerminal.tsx
+++ b/src/renderer/components/terminal/EmbeddedTerminal.tsx
@@ -16,6 +16,8 @@ interface EmbeddedTerminalProps {
args?: string[];
/** Working directory */
cwd?: string;
+ /** Environment variables merged into the PTY process env */
+ env?: Record;
/** Callback when PTY process exits */
onExit?: (exitCode: number) => void;
/** CSS class for container */
@@ -26,6 +28,7 @@ export const EmbeddedTerminal = ({
command,
args,
cwd,
+ env,
onExit,
className,
}: EmbeddedTerminalProps): React.JSX.Element => {
@@ -99,6 +102,7 @@ export const EmbeddedTerminal = ({
...(command ? { command } : {}),
...(args ? { args } : {}),
...(cwd ? { cwd } : {}),
+ ...(env ? { env } : {}),
cols: term.cols,
rows: term.rows,
};
diff --git a/src/renderer/components/terminal/TerminalModal.tsx b/src/renderer/components/terminal/TerminalModal.tsx
index a7c7f4e7..88c8dc37 100644
--- a/src/renderer/components/terminal/TerminalModal.tsx
+++ b/src/renderer/components/terminal/TerminalModal.tsx
@@ -14,6 +14,8 @@ interface TerminalModalProps {
args?: string[];
/** Working directory */
cwd?: string;
+ /** Environment variables merged into the PTY process env */
+ env?: Record;
/** Called when the modal should close */
onClose: () => void;
/** Called when the PTY process exits */
@@ -31,6 +33,7 @@ export function TerminalModal({
command,
args,
cwd,
+ env,
onClose,
onExit,
autoCloseOnSuccessMs = 0,
@@ -116,7 +119,7 @@ export function TerminalModal({
{/* Terminal area — always visible, status bar overlaid at bottom */}
-
+
{exited !== null && (
void {
cliInstallerError: progress.error ?? 'Unknown error',
});
break;
+ case 'status':
+ if (progress.status) {
+ useStore.setState({ cliStatus: progress.status });
+ }
+ break;
}
});
if (typeof cleanup === 'function') {
diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts
index 9149ead1..00433e24 100644
--- a/src/shared/types/cliInstaller.ts
+++ b/src/shared/types/cliInstaller.ts
@@ -21,6 +21,40 @@ export type CliPlatform =
| 'win32-x64'
| 'win32-arm64';
+export type CliFlavor = 'claude' | 'free-code';
+
+export type CliProviderId = 'anthropic' | 'codex' | 'gemini';
+
+export interface CliProviderStatus {
+ providerId: CliProviderId;
+ displayName: string;
+ supported: boolean;
+ authenticated: boolean;
+ authMethod: string | null;
+ verificationState: 'verified' | 'unknown' | 'offline' | 'error';
+ statusMessage?: string | null;
+ models: string[];
+ canLoginFromUi: boolean;
+ capabilities: {
+ teamLaunch: boolean;
+ oneShot: boolean;
+ };
+ backend?: {
+ kind: string;
+ label: string;
+ endpointLabel?: string | null;
+ projectId?: string | null;
+ authMethodDetail?: string | null;
+ } | null;
+}
+
+export interface CliFlavorUiOptions {
+ displayName: string;
+ supportsSelfUpdate: boolean;
+ showVersionDetails: boolean;
+ showBinaryPath: boolean;
+}
+
// =============================================================================
// Installation Status
// =============================================================================
@@ -29,6 +63,16 @@ export type CliPlatform =
* Current CLI installation status returned by getStatus().
*/
export interface CliInstallationStatus {
+ /** Selected CLI runtime flavor */
+ flavor: CliFlavor;
+ /** Display label for the configured runtime */
+ displayName: string;
+ /** Whether this runtime should expose self-update/install actions in the UI */
+ supportsSelfUpdate: boolean;
+ /** Whether version text should be shown in the UI */
+ showVersionDetails: boolean;
+ /** Whether binary path should be shown in the UI */
+ showBinaryPath: boolean;
/** Whether CLI binary is found on the system */
installed: boolean;
/** Installed version string (e.g. "2.1.59"), null if not installed */
@@ -43,6 +87,8 @@ export interface CliInstallationStatus {
authLoggedIn: boolean;
/** Auth method if logged in (e.g. "oauth_token", "api_key"), null otherwise */
authMethod: string | null;
+ /** Provider-level runtime status when supported by the configured runtime */
+ providers: CliProviderStatus[];
}
// =============================================================================
@@ -54,7 +100,7 @@ export interface CliInstallationStatus {
*/
export interface CliInstallerProgress {
/** Current phase of the installation process */
- type: 'checking' | 'downloading' | 'verifying' | 'installing' | 'completed' | 'error';
+ type: 'checking' | 'downloading' | 'verifying' | 'installing' | 'completed' | 'error' | 'status';
/** Download progress 0-100, only present for 'downloading' */
percent?: number;
/** Bytes downloaded so far */
@@ -69,6 +115,8 @@ export interface CliInstallerProgress {
detail?: string;
/** Raw terminal output chunk (with ANSI codes), only for 'installing' */
rawChunk?: string;
+ /** Partial or full CLI status snapshot during status gathering. */
+ status?: CliInstallationStatus;
}
// =============================================================================
diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts
index cd36ca7b..f273bf23 100644
--- a/src/shared/types/notifications.ts
+++ b/src/shared/types/notifications.ts
@@ -308,6 +308,8 @@ export interface AppConfig {
theme: 'dark' | 'light' | 'system';
/** Default tab to show on app launch */
defaultTab: 'dashboard' | 'last-session';
+ /** Whether to use the multimodel runtime instead of the stock Claude CLI */
+ multimodelEnabled: boolean;
/** Optional custom Claude root folder (auto-detected when null) */
claudeRootPath: string | null;
/** Agent communication language ('system' = use OS locale) */
diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
new file mode 100644
index 00000000..6a45cd38
--- /dev/null
+++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
@@ -0,0 +1,160 @@
+// @vitest-environment node
+import type { PathLike } from 'fs';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const execCliMock = vi.fn();
+const buildEnrichedEnvMock = vi.fn<(binaryPath: string) => NodeJS.ProcessEnv>();
+const getCachedShellEnvMock = vi.fn<() => NodeJS.ProcessEnv | null>();
+const getShellPreferredHomeMock = vi.fn<() => string>();
+const resolveInteractiveShellEnvMock = vi.fn<() => Promise>();
+const readFileMock = vi.fn<(path: PathLike, encoding: BufferEncoding) => Promise>();
+
+vi.mock('@main/utils/childProcess', () => ({
+ execCli: (...args: Parameters) => execCliMock(...args),
+}));
+
+vi.mock('@main/utils/cliEnv', () => ({
+ buildEnrichedEnv: (binaryPath: string) => buildEnrichedEnvMock(binaryPath),
+}));
+
+vi.mock('@main/utils/shellEnv', () => ({
+ getCachedShellEnv: () => getCachedShellEnvMock(),
+ getShellPreferredHome: () => getShellPreferredHomeMock(),
+ resolveInteractiveShellEnv: () => resolveInteractiveShellEnvMock(),
+}));
+
+vi.mock('fs', () => ({
+ default: {
+ promises: {
+ readFile: (filePath: PathLike, encoding: BufferEncoding) => readFileMock(filePath, encoding),
+ },
+ },
+ promises: {
+ readFile: (filePath: PathLike, encoding: BufferEncoding) => readFileMock(filePath, encoding),
+ },
+}));
+
+describe('ClaudeMultimodelBridgeService', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ buildEnrichedEnvMock.mockReturnValue({});
+ getCachedShellEnvMock.mockReturnValue({});
+ getShellPreferredHomeMock.mockReturnValue('/Users/tester');
+ resolveInteractiveShellEnvMock.mockResolvedValue({});
+ readFileMock.mockImplementation(async (filePath) => {
+ if (String(filePath) === '/Users/tester/.claude.json') {
+ return JSON.stringify({
+ geminiResolvedBackend: 'cli',
+ geminiLastAuthMethod: 'cli_oauth_personal',
+ geminiProjectId: 'demo-project',
+ });
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
+ });
+ });
+
+ it('parses object-based model lists and exposes Gemini runtime status', async () => {
+ execCliMock.mockImplementation(async (_binaryPath, args, options) => {
+ const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
+ const env = options?.env ?? {};
+
+ if (normalizedArgs === 'auth status --json --provider all') {
+ return {
+ stdout: JSON.stringify({
+ providers: {
+ anthropic: {
+ supported: true,
+ authenticated: true,
+ authMethod: 'oauth_token',
+ verificationState: 'verified',
+ canLoginFromUi: true,
+ capabilities: { teamLaunch: true, oneShot: true },
+ backend: { kind: 'anthropic', label: 'Anthropic' },
+ },
+ codex: {
+ supported: true,
+ authenticated: false,
+ verificationState: 'verified',
+ canLoginFromUi: true,
+ statusMessage: 'Not connected',
+ capabilities: { teamLaunch: true, oneShot: true },
+ backend: { kind: 'openai', label: 'OpenAI' },
+ },
+ },
+ }),
+ stderr: '',
+ exitCode: 0,
+ };
+ }
+
+ if (normalizedArgs === 'model list --json --provider all' && env.CLAUDE_CODE_USE_GEMINI === '1') {
+ return {
+ stdout: JSON.stringify({
+ providers: {
+ gemini: {
+ models: [{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }],
+ },
+ },
+ }),
+ stderr: '',
+ exitCode: 0,
+ };
+ }
+
+ if (normalizedArgs === 'model list --json --provider all') {
+ return {
+ stdout: JSON.stringify({
+ providers: {
+ anthropic: {
+ models: [{ id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }],
+ },
+ codex: {
+ models: [{ id: 'gpt-5-codex', label: 'GPT-5 Codex' }],
+ },
+ },
+ }),
+ stderr: '',
+ exitCode: 0,
+ };
+ }
+
+ throw new Error(`Unexpected execCli call: ${normalizedArgs}`);
+ });
+
+ const { ClaudeMultimodelBridgeService } = await import(
+ '@main/services/runtime/ClaudeMultimodelBridgeService'
+ );
+ const service = new ClaudeMultimodelBridgeService();
+
+ const providers = await service.getProviderStatuses('/mock/free-code');
+
+ expect(providers).toHaveLength(3);
+ expect(providers[0]).toMatchObject({
+ providerId: 'anthropic',
+ authenticated: true,
+ models: ['claude-sonnet-4-5'],
+ });
+ expect(providers[1]).toMatchObject({
+ providerId: 'codex',
+ authenticated: false,
+ models: ['gpt-5-codex'],
+ statusMessage: 'Not connected',
+ });
+ expect(providers[2]).toMatchObject({
+ providerId: 'gemini',
+ displayName: 'Gemini',
+ supported: true,
+ authenticated: true,
+ models: ['gemini-2.5-pro'],
+ canLoginFromUi: true,
+ authMethod: 'cli_oauth_personal',
+ backend: {
+ kind: 'cli',
+ label: 'Gemini CLI',
+ endpointLabel: 'Code Assist (cloudcode-pa.googleapis.com/v1internal)',
+ projectId: 'demo-project',
+ },
+ });
+ });
+});
diff --git a/test/main/services/runtime/providerRuntimeEnv.test.ts b/test/main/services/runtime/providerRuntimeEnv.test.ts
new file mode 100644
index 00000000..e7f4d92a
--- /dev/null
+++ b/test/main/services/runtime/providerRuntimeEnv.test.ts
@@ -0,0 +1,29 @@
+// @vitest-environment node
+import { describe, expect, it } from 'vitest';
+
+import {
+ applyProviderRuntimeEnv,
+ resolveTeamProviderId,
+} from '@main/services/runtime/providerRuntimeEnv';
+
+describe('providerRuntimeEnv', () => {
+ it('enables Gemini runtime mode and clears other third-party provider flags', () => {
+ const env: NodeJS.ProcessEnv = {
+ CLAUDE_CODE_USE_OPENAI: '1',
+ CLAUDE_CODE_USE_GEMINI: undefined,
+ CLAUDE_CODE_USE_BEDROCK: '1',
+ };
+
+ const result = applyProviderRuntimeEnv(env, 'gemini');
+
+ expect(result.CLAUDE_CODE_USE_GEMINI).toBe('1');
+ expect(result.CLAUDE_CODE_USE_OPENAI).toBeUndefined();
+ expect(result.CLAUDE_CODE_USE_BEDROCK).toBeUndefined();
+ });
+
+ it('preserves gemini as a valid team provider id', () => {
+ expect(resolveTeamProviderId('gemini')).toBe('gemini');
+ expect(resolveTeamProviderId('codex')).toBe('codex');
+ expect(resolveTeamProviderId(undefined)).toBe('anthropic');
+ });
+});
diff --git a/test/main/services/team/ClaudeBinaryResolver.test.ts b/test/main/services/team/ClaudeBinaryResolver.test.ts
new file mode 100644
index 00000000..b65a6098
--- /dev/null
+++ b/test/main/services/team/ClaudeBinaryResolver.test.ts
@@ -0,0 +1,94 @@
+// @vitest-environment node
+import type { PathLike } from 'fs';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mockBuildMergedCliPath = vi.fn<(binaryPath: string | null) => string>();
+const mockGetShellPreferredHome = vi.fn<() => string>();
+const mockResolveInteractiveShellEnv = vi.fn<() => Promise>();
+const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'free-code'>();
+
+const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise>();
+const statMock = vi.fn<
+ (filePath: PathLike) => Promise<{ isFile: () => boolean }>
+>();
+const readdirMock = vi.fn<(filePath: PathLike) => Promise>();
+
+vi.mock('@main/utils/cliPathMerge', () => ({
+ buildMergedCliPath: (binaryPath: string | null) => mockBuildMergedCliPath(binaryPath),
+}));
+
+vi.mock('@main/utils/shellEnv', () => ({
+ getShellPreferredHome: () => mockGetShellPreferredHome(),
+ resolveInteractiveShellEnv: () => mockResolveInteractiveShellEnv(),
+}));
+
+vi.mock('@main/services/team/cliFlavor', () => ({
+ getConfiguredCliFlavor: () => mockGetConfiguredCliFlavor(),
+}));
+
+vi.mock('fs', () => ({
+ default: {
+ constants: { X_OK: 1 },
+ promises: {
+ access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
+ stat: (filePath: PathLike) => statMock(filePath),
+ readdir: (filePath: PathLike) => readdirMock(filePath),
+ },
+ },
+ constants: { X_OK: 1 },
+ promises: {
+ access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
+ stat: (filePath: PathLike) => statMock(filePath),
+ readdir: (filePath: PathLike) => readdirMock(filePath),
+ },
+}));
+
+describe('ClaudeBinaryResolver', () => {
+ const originalPlatform = process.platform;
+ const originalCwd = process.cwd;
+ const workspaceRoot = '/Users/belief/dev/projects/claude/claude_team_freecode';
+
+ beforeEach(() => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ mockBuildMergedCliPath.mockReturnValue('/usr/local/bin:/usr/bin');
+ mockGetShellPreferredHome.mockReturnValue('/Users/tester');
+ mockResolveInteractiveShellEnv.mockResolvedValue({});
+ mockGetConfiguredCliFlavor.mockReturnValue('free-code');
+ readdirMock.mockResolvedValue([]);
+ Object.defineProperty(process, 'platform', {
+ value: 'darwin',
+ configurable: true,
+ writable: true,
+ });
+ process.cwd = vi.fn(() => workspaceRoot);
+ delete process.env.CLAUDE_CLI_PATH;
+ });
+
+ afterEach(() => {
+ Object.defineProperty(process, 'platform', {
+ value: originalPlatform,
+ configurable: true,
+ writable: true,
+ });
+ process.cwd = originalCwd;
+ vi.unstubAllEnvs();
+ });
+
+ it('resolves free-code runtime from the free-code-gemini-research sibling repo', async () => {
+ const expectedBinary = '/Users/belief/dev/projects/claude/free-code-gemini-research/cli';
+
+ accessMock.mockImplementation(async (filePath) => {
+ if (filePath === expectedBinary) {
+ return;
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
+ });
+
+ const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
+ ClaudeBinaryResolver.clearCache();
+
+ await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
+ expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
+ });
+});
diff --git a/test/main/services/team/cliFlavor.test.ts b/test/main/services/team/cliFlavor.test.ts
new file mode 100644
index 00000000..a4612c84
--- /dev/null
+++ b/test/main/services/team/cliFlavor.test.ts
@@ -0,0 +1,55 @@
+// @vitest-environment node
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const getConfigMock = vi.fn();
+
+vi.mock('@main/services/infrastructure/ConfigManager', () => ({
+ configManager: {
+ getConfig: () => getConfigMock(),
+ },
+}));
+
+describe('cliFlavor', () => {
+ afterEach(() => {
+ delete process.env.CLAUDE_TEAM_CLI_FLAVOR;
+ vi.resetModules();
+ vi.clearAllMocks();
+ });
+
+ it('uses multimodel runtime by default when config enables it', async () => {
+ getConfigMock.mockReturnValue({
+ general: {
+ multimodelEnabled: true,
+ },
+ });
+
+ const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
+
+ expect(getConfiguredCliFlavor()).toBe('free-code');
+ });
+
+ it('uses claude runtime when multimodel is disabled in config', async () => {
+ getConfigMock.mockReturnValue({
+ general: {
+ multimodelEnabled: false,
+ },
+ });
+
+ const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
+
+ expect(getConfiguredCliFlavor()).toBe('claude');
+ });
+
+ it('lets env override the persisted config', async () => {
+ process.env.CLAUDE_TEAM_CLI_FLAVOR = 'claude';
+ getConfigMock.mockReturnValue({
+ general: {
+ multimodelEnabled: true,
+ },
+ });
+
+ const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
+
+ expect(getConfiguredCliFlavor()).toBe('claude');
+ });
+});
diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts
index d9dd9e92..7177eee6 100644
--- a/test/renderer/store/cliInstallerSlice.test.ts
+++ b/test/renderer/store/cliInstallerSlice.test.ts
@@ -83,6 +83,11 @@ describe('cliInstallerSlice', () => {
describe('fetchCliStatus', () => {
it('updates cliStatus from API', async () => {
const mockStatus: CliInstallationStatus = {
+ flavor: 'claude',
+ displayName: 'Claude CLI',
+ supportsSelfUpdate: true,
+ showVersionDetails: true,
+ showBinaryPath: true,
installed: true,
installedVersion: '2.1.59',
binaryPath: '/usr/local/bin/claude',
@@ -90,6 +95,7 @@ describe('cliInstallerSlice', () => {
updateAvailable: false,
authLoggedIn: false,
authMethod: null,
+ providers: [],
};
vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus);
@@ -109,6 +115,11 @@ describe('cliInstallerSlice', () => {
it('detects update available', async () => {
const mockStatus: CliInstallationStatus = {
+ flavor: 'claude',
+ displayName: 'Claude CLI',
+ supportsSelfUpdate: true,
+ showVersionDetails: true,
+ showBinaryPath: true,
installed: true,
installedVersion: '2.1.34',
binaryPath: '/usr/local/bin/claude',
@@ -116,6 +127,7 @@ describe('cliInstallerSlice', () => {
updateAvailable: true,
authLoggedIn: true,
authMethod: 'oauth_token',
+ providers: [],
};
vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus);
From 3ac46e2861333f95a213ea8cc2b35dc36cfe6477 Mon Sep 17 00:00:00 2001
From: iliya
Date: Thu, 2 Apr 2026 10:23:14 +0300
Subject: [PATCH 02/67] feat(teams): unify provider-aware create and launch
flows
---
src/main/http/teams.ts | 11 +
src/main/ipc/teams.ts | 263 +++++++-
.../infrastructure/EventLoopLagMonitor.ts | 5 +
src/main/services/team/TeamDataService.ts | 39 +-
src/main/services/team/TeamMemberResolver.ts | 39 +-
.../services/team/TeamMembersMetaStore.ts | 9 +
src/main/services/team/TeamMetaStore.ts | 8 +
.../services/team/TeamProvisioningService.ts | 597 ++++++++++++++----
.../team/memberUpdateNotifications.ts | 154 +++++
src/renderer/api/httpClient.ts | 12 +-
.../components/team/TeamDetailView.tsx | 8 +
.../team/dialogs/AddMemberDialog.tsx | 7 +
.../team/dialogs/CreateTeamDialog.tsx | 402 ++++++++----
.../team/dialogs/EditTeamDialog.tsx | 27 +-
.../team/dialogs/EffortLevelSelector.tsx | 4 +-
.../team/dialogs/LaunchTeamDialog.tsx | 437 ++++++++++---
.../ProvisioningProviderStatusList.tsx | 258 ++++++++
.../team/dialogs/TeamModelSelector.tsx | 329 ++++++----
.../components/team/members/LeadModelRow.tsx | 138 ++++
.../components/team/members/MemberCard.tsx | 77 ++-
.../team/members/MemberDraftRow.tsx | 153 +++--
.../components/team/members/MemberList.tsx | 48 ++
.../team/members/MembersEditorSection.tsx | 84 ++-
.../team/members/TeamRosterEditorSection.tsx | 121 ++++
.../team/members/membersEditorTypes.ts | 4 +
.../team/members/membersEditorUtils.ts | 70 +-
src/renderer/hooks/useCreateTeamDraft.ts | 43 +-
.../services/createTeamDraftStorage.ts | 17 +-
src/renderer/store/slices/teamSlice.ts | 3 +
src/shared/types/api.ts | 6 +-
src/shared/types/schedule.ts | 3 +-
src/shared/types/team.ts | 15 +
src/shared/utils/cliArgsParser.ts | 6 +
.../services/team/TeamMemberResolver.test.ts | 22 +
.../TeamProvisioningServicePrepare.test.ts | 32 +
.../TeamProvisioningServiceRoster.test.ts | 21 +
36 files changed, 2898 insertions(+), 574 deletions(-)
create mode 100644 src/main/services/team/memberUpdateNotifications.ts
create mode 100644 src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx
create mode 100644 src/renderer/components/team/members/LeadModelRow.tsx
create mode 100644 src/renderer/components/team/members/TeamRosterEditorSection.tsx
diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts
index ed177e9c..77d9feb9 100644
--- a/src/main/http/teams.ts
+++ b/src/main/http/teams.ts
@@ -89,6 +89,16 @@ function assertOptionalEffort(value: unknown): EffortLevel | undefined {
function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest {
const payload = body && typeof body === 'object' ? (body as Record) : {};
+ const providerId =
+ payload.providerId === 'codex'
+ ? 'codex'
+ : payload.providerId === 'gemini'
+ ? 'gemini'
+ : payload.providerId == null || payload.providerId === 'anthropic'
+ ? 'anthropic'
+ : (() => {
+ throw new HttpBadRequestError('providerId must be anthropic, codex, or gemini');
+ })();
const prompt = assertOptionalString(payload.prompt, 'prompt');
const model = assertOptionalString(payload.model, 'model');
const effort = assertOptionalEffort(payload.effort);
@@ -100,6 +110,7 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
return {
teamName,
cwd: assertAbsoluteCwd(payload.cwd),
+ providerId,
...(prompt && {
prompt,
}),
diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts
index 4848902b..e0e17945 100644
--- a/src/main/ipc/teams.ts
+++ b/src/main/ipc/teams.ts
@@ -101,6 +101,10 @@ import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
import { TeamMetaStore } from '../services/team/TeamMetaStore';
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
+import {
+ buildReplaceMembersDiff,
+ buildReplaceMembersSummaryMessage,
+} from '../services/team/memberUpdateNotifications';
import {
validateFromField,
@@ -534,25 +538,30 @@ async function handleGetData(
const tn = validated.value!;
const startedAt = Date.now();
let data: TeamData;
+ setCurrentMainOp('team:getData');
try {
- data = await getTeamDataService().getTeamData(tn);
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- if (
- message === `Team not found: ${tn}` &&
- getTeamProvisioningService().hasProvisioningRun(tn)
- ) {
- return { success: false, error: 'TEAM_PROVISIONING' };
- }
- // Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate)
- if (message === `Team not found: ${tn}`) {
- const meta = await teamMetaStore.getMeta(tn);
- if (meta) {
- return { success: false, error: 'TEAM_DRAFT' };
+ try {
+ data = await getTeamDataService().getTeamData(tn);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ if (
+ message === `Team not found: ${tn}` &&
+ getTeamProvisioningService().hasProvisioningRun(tn)
+ ) {
+ return { success: false, error: 'TEAM_PROVISIONING' };
}
+ // Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate)
+ if (message === `Team not found: ${tn}`) {
+ const meta = await teamMetaStore.getMeta(tn);
+ if (meta) {
+ return { success: false, error: 'TEAM_DRAFT' };
+ }
+ }
+ logger.error(`[teams:getData] ${message}`);
+ return { success: false, error: message };
}
- logger.error(`[teams:getData] ${message}`);
- return { success: false, error: message };
+ } finally {
+ setCurrentMainOp(null);
}
const getDataMs = Date.now() - startedAt;
if (getDataMs >= 1500) {
@@ -833,6 +842,32 @@ function isValidEffort(value: unknown): value is EffortLevel {
return typeof value === 'string' && VALID_EFFORT_LEVELS.includes(value);
}
+function parseOptionalMemberProviderId(
+ value: unknown
+):
+ | { valid: true; value: 'anthropic' | 'codex' | 'gemini' | undefined }
+ | { valid: false; error: string } {
+ if (value === undefined || value === null || value === '') {
+ return { valid: true, value: undefined };
+ }
+ if (value === 'anthropic' || value === 'codex' || value === 'gemini') {
+ return { valid: true, value };
+ }
+ return { valid: false, error: 'member providerId must be anthropic, codex, or gemini' };
+}
+
+function parseOptionalMemberEffort(
+ value: unknown
+): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } {
+ if (value === undefined || value === null || value === '') {
+ return { valid: true, value: undefined };
+ }
+ if (isValidEffort(value)) {
+ return { valid: true, value };
+ }
+ return { valid: false, error: 'member effort must be low, medium, or high' };
+}
+
async function validateProvisioningRequest(
request: unknown
): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> {
@@ -884,10 +919,22 @@ async function validateProvisioningRequest(
if (workflow !== undefined && typeof workflow !== 'string') {
return { valid: false, error: 'member workflow must be string' };
}
+ const providerValidation = parseOptionalMemberProviderId(
+ (member as { providerId?: unknown }).providerId
+ );
+ if (!providerValidation.valid) {
+ return { valid: false, error: providerValidation.error };
+ }
+ const model = (member as { model?: unknown }).model;
+ if (model !== undefined && typeof model !== 'string') {
+ return { valid: false, error: 'member model must be string' };
+ }
members.push({
name: memberName,
role: typeof role === 'string' ? role.trim() : undefined,
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
+ providerId: providerValidation.value,
+ model: typeof model === 'string' ? model.trim() || undefined : undefined,
});
}
@@ -953,6 +1000,12 @@ async function validateProvisioningRequest(
members,
cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
+ providerId:
+ payload.providerId === 'codex'
+ ? 'codex'
+ : payload.providerId === 'gemini'
+ ? 'gemini'
+ : 'anthropic',
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
skipPermissions:
@@ -1089,6 +1142,16 @@ async function handleLaunchTeam(
color: meta?.color,
cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
+ providerId:
+ payload.providerId === 'codex'
+ ? 'codex'
+ : payload.providerId === 'gemini'
+ ? 'gemini'
+ : meta?.providerId === 'codex'
+ ? 'codex'
+ : meta?.providerId === 'gemini'
+ ? 'gemini'
+ : 'anthropic',
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
@@ -1100,7 +1163,14 @@ async function handleLaunchTeam(
typeof payload.extraCliArgs === 'string'
? payload.extraCliArgs.trim() || undefined
: undefined,
- members: members.map((m) => ({ name: m.name, role: m.role, workflow: m.workflow })),
+ members: members.map((m) => ({
+ name: m.name,
+ role: m.role,
+ workflow: m.workflow,
+ providerId: m.providerId,
+ model: m.model,
+ effort: m.effort,
+ })),
};
return wrapTeamHandler('create', () =>
@@ -1122,6 +1192,12 @@ async function handleLaunchTeam(
teamName: validatedTeamName.value!,
cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
+ providerId:
+ payload.providerId === 'codex'
+ ? 'codex'
+ : payload.providerId === 'gemini'
+ ? 'gemini'
+ : 'anthropic',
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
clearContext: payload.clearContext === true ? true : undefined,
@@ -1174,9 +1250,13 @@ async function handleValidateCliArgs(
async function handlePrepareProvisioning(
_event: IpcMainInvokeEvent,
- cwd: unknown
+ cwd: unknown,
+ providerId: unknown,
+ providerIds: unknown
): Promise> {
let validatedCwd: string | undefined;
+ let validatedProviderId: TeamLaunchRequest['providerId'];
+ let validatedProviderIds: Array<'anthropic' | 'codex' | 'gemini'> | undefined;
if (cwd !== undefined) {
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
return { success: false, error: 'cwd must be a non-empty string' };
@@ -1186,8 +1266,32 @@ async function handlePrepareProvisioning(
return { success: false, error: 'cwd must be an absolute path' };
}
}
+ if (providerId !== undefined) {
+ if (providerId !== 'anthropic' && providerId !== 'codex' && providerId !== 'gemini') {
+ return { success: false, error: 'providerId must be anthropic, codex, or gemini' };
+ }
+ validatedProviderId = providerId;
+ }
+ if (providerIds !== undefined) {
+ if (!Array.isArray(providerIds)) {
+ return { success: false, error: 'providerIds must be an array when provided' };
+ }
+ const normalized: Array<'anthropic' | 'codex' | 'gemini'> = [];
+ for (const entry of providerIds) {
+ if (entry !== 'anthropic' && entry !== 'codex' && entry !== 'gemini') {
+ return { success: false, error: 'providerIds entries must be anthropic, codex, or gemini' };
+ }
+ if (!normalized.includes(entry)) {
+ normalized.push(entry);
+ }
+ }
+ validatedProviderIds = normalized;
+ }
return wrapTeamHandler('prepareProvisioning', () =>
- getTeamProvisioningService().prepareForProvisioning(validatedCwd)
+ getTeamProvisioningService().prepareForProvisioning(validatedCwd, {
+ providerId: validatedProviderId,
+ providerIds: validatedProviderIds,
+ })
);
}
@@ -2057,10 +2161,27 @@ async function handleCreateConfig(
if (workflow !== undefined && typeof workflow !== 'string') {
return { success: false, error: 'member workflow must be string' };
}
+ const providerValidation = parseOptionalMemberProviderId(
+ (member as { providerId?: unknown }).providerId
+ );
+ if (!providerValidation.valid) {
+ return { success: false, error: providerValidation.error };
+ }
+ const model = (member as { model?: unknown }).model;
+ if (model !== undefined && typeof model !== 'string') {
+ return { success: false, error: 'member model must be string' };
+ }
+ const effortValidation = parseOptionalMemberEffort((member as { effort?: unknown }).effort);
+ if (!effortValidation.valid) {
+ return { success: false, error: effortValidation.error };
+ }
members.push({
name: memberName,
role: typeof role === 'string' ? role.trim() : undefined,
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
+ providerId: providerValidation.value,
+ model: typeof model === 'string' ? model.trim() || undefined : undefined,
+ effort: effortValidation.value,
});
}
@@ -2286,10 +2407,13 @@ async function handleAddMember(
if (!payload || typeof payload !== 'object') {
return { success: false, error: 'Invalid payload' };
}
- const { name, role, workflow } = payload as {
+ const { name, role, workflow, providerId, model } = payload as {
name?: unknown;
role?: unknown;
workflow?: unknown;
+ providerId?: unknown;
+ model?: unknown;
+ effort?: unknown;
};
const vName = validateTeammateName(name);
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' };
@@ -2299,6 +2423,17 @@ async function handleAddMember(
if (workflow !== undefined && typeof workflow !== 'string') {
return { success: false, error: 'workflow must be a string' };
}
+ const providerValidation = parseOptionalMemberProviderId(providerId);
+ if (!providerValidation.valid) {
+ return { success: false, error: providerValidation.error };
+ }
+ if (model !== undefined && typeof model !== 'string') {
+ return { success: false, error: 'model must be a string' };
+ }
+ const effortValidation = parseOptionalMemberEffort((payload as { effort?: unknown }).effort);
+ if (!effortValidation.valid) {
+ return { success: false, error: effortValidation.error };
+ }
return wrapTeamHandler('addMember', async () => {
const tn = vTeam.value!;
@@ -2307,6 +2442,9 @@ async function handleAddMember(
name: memberName,
role: role,
workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined,
+ providerId: providerValidation.value,
+ model: typeof model === 'string' ? model.trim() || undefined : undefined,
+ effort: effortValidation.value,
});
// If team is alive, notify the lead to spawn the new teammate
@@ -2329,6 +2467,9 @@ async function handleAddMember(
name: memberName,
...(typeof role === 'string' ? { role } : {}),
...(typeof workflow === 'string' ? { workflow } : {}),
+ ...(providerValidation.value ? { providerId: providerValidation.value } : {}),
+ ...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}),
+ ...(effortValidation.value ? { effort: effortValidation.value } : {}),
});
try {
await provisioning.sendMessageToTeam(tn, spawnMessage);
@@ -2355,12 +2496,26 @@ async function handleReplaceMembers(
return { success: false, error: 'members must be an array' };
}
const seenNames = new Set();
- const members: { name: string; role?: string; workflow?: string }[] = [];
+ const members: {
+ name: string;
+ role?: string;
+ workflow?: string;
+ providerId?: 'anthropic' | 'codex' | 'gemini';
+ model?: string;
+ effort?: 'low' | 'medium' | 'high';
+ }[] = [];
for (const item of payload.members) {
if (!item || typeof item !== 'object') {
return { success: false, error: 'member must be object' };
}
- const m = item as { name?: unknown; role?: unknown; workflow?: unknown };
+ const m = item as {
+ name?: unknown;
+ role?: unknown;
+ workflow?: unknown;
+ providerId?: unknown;
+ model?: unknown;
+ effort?: unknown;
+ };
const vName = validateTeammateName(m.name);
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' };
const name = vName.value!;
@@ -2372,15 +2527,73 @@ async function handleReplaceMembers(
if (m.workflow !== undefined && typeof m.workflow !== 'string') {
return { success: false, error: 'member workflow must be string' };
}
+ const providerValidation = parseOptionalMemberProviderId(
+ (m as { providerId?: unknown }).providerId
+ );
+ if (!providerValidation.valid) {
+ return { success: false, error: providerValidation.error };
+ }
+ if (m.model !== undefined && typeof m.model !== 'string') {
+ return { success: false, error: 'member model must be string' };
+ }
+ const effortValidation = parseOptionalMemberEffort((m as { effort?: unknown }).effort);
+ if (!effortValidation.valid) {
+ return { success: false, error: effortValidation.error };
+ }
members.push({
name,
role: typeof m.role === 'string' ? m.role.trim() : undefined,
workflow: typeof m.workflow === 'string' ? m.workflow.trim() : undefined,
+ providerId: providerValidation.value,
+ model: typeof m.model === 'string' ? m.model.trim() || undefined : undefined,
+ effort: effortValidation.value,
});
}
return wrapTeamHandler('replaceMembers', async () => {
- await getTeamDataService().replaceMembers(vTeam.value!, { members });
+ const tn = vTeam.value!;
+ const teamDataService = getTeamDataService();
+ const previousMembers = (await teamDataService.getTeamData(tn)).members;
+ const diff = buildReplaceMembersDiff(previousMembers, members);
+
+ await teamDataService.replaceMembers(tn, { members });
+
+ const provisioning = getTeamProvisioningService();
+ if (!provisioning.isTeamAlive(tn)) {
+ return;
+ }
+
+ let leadName = 'team-lead';
+ let displayName = tn;
+ try {
+ const [resolvedLeadName, resolvedDisplayName] = await Promise.all([
+ teamDataService.getLeadMemberName(tn),
+ teamDataService.getTeamDisplayName(tn),
+ ]);
+ leadName = resolvedLeadName || 'team-lead';
+ displayName = resolvedDisplayName || tn;
+ } catch {
+ // Best-effort: fall back to default lead and team names
+ }
+
+ for (const addedMember of diff.added) {
+ const spawnMessage = buildAddMemberSpawnMessage(tn, displayName, leadName, addedMember);
+ try {
+ await provisioning.sendMessageToTeam(tn, spawnMessage);
+ } catch {
+ logger.warn(`Failed to notify lead about new member "${addedMember.name}" in ${tn}`);
+ }
+ }
+
+ const summaryMessage = buildReplaceMembersSummaryMessage(diff);
+ if (!summaryMessage) {
+ return;
+ }
+ try {
+ await provisioning.sendMessageToTeam(tn, summaryMessage);
+ } catch {
+ logger.warn(`Failed to notify lead about member updates in ${tn}`);
+ }
});
}
@@ -3088,6 +3301,7 @@ async function handleGetSavedRequest(
color: meta.color,
cwd: meta.cwd,
prompt: meta.prompt,
+ providerId: meta.providerId ?? 'anthropic',
model: meta.model,
effort: meta.effort as TeamCreateRequest['effort'],
skipPermissions: meta.skipPermissions,
@@ -3098,6 +3312,9 @@ async function handleGetSavedRequest(
name: m.name,
role: m.role,
workflow: m.workflow,
+ providerId: m.providerId,
+ model: m.model,
+ effort: m.effort,
})),
},
};
diff --git a/src/main/services/infrastructure/EventLoopLagMonitor.ts b/src/main/services/infrastructure/EventLoopLagMonitor.ts
index 7af62b24..c47fe9b8 100644
--- a/src/main/services/infrastructure/EventLoopLagMonitor.ts
+++ b/src/main/services/infrastructure/EventLoopLagMonitor.ts
@@ -27,6 +27,11 @@ export function startEventLoopLagMonitor(): void {
// Only report meaningful stalls
if (maxMs < 250) return;
+ // For known IPC/main-thread operations we already emit operation-specific
+ // timing diagnostics. Suppress the generic event-loop warning to avoid
+ // duplicate noisy logs that do not add new debugging value.
+ if (currentOp) return;
+
logger.warn(
`Event loop stall detected: p95=${p95Ms.toFixed(1)}ms max=${maxMs.toFixed(1)}ms` +
(currentOp ? ` op=${currentOp}` : '')
diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts
index 92bdbf5e..e3bc315f 100644
--- a/src/main/services/team/TeamDataService.ts
+++ b/src/main/services/team/TeamDataService.ts
@@ -948,6 +948,15 @@ export class TeamDataService {
name,
role: request.role?.trim() || undefined,
workflow: request.workflow?.trim() || undefined,
+ providerId:
+ request.providerId === 'codex' || request.providerId === 'gemini'
+ ? request.providerId
+ : undefined,
+ model: request.model?.trim() || undefined,
+ effort:
+ request.effort === 'low' || request.effort === 'medium' || request.effort === 'high'
+ ? request.effort
+ : undefined,
agentType: 'general-purpose',
color: getMemberColorByName(name),
joinedAt: Date.now(),
@@ -977,7 +986,16 @@ export class TeamDataService {
async replaceMembers(
teamName: string,
- request: { members: { name: string; role?: string; workflow?: string }[] }
+ request: {
+ members: {
+ name: string;
+ role?: string;
+ workflow?: string;
+ providerId?: 'anthropic' | 'codex' | 'gemini';
+ model?: string;
+ effort?: 'low' | 'medium' | 'high';
+ }[];
+ }
): Promise {
const existing = await this.membersMetaStore.getMembers(teamName);
const existingLead = existing.find(isLeadMember) ?? null;
@@ -1003,6 +1021,15 @@ export class TeamDataService {
name,
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
+ providerId:
+ member.providerId === 'codex' || member.providerId === 'gemini'
+ ? member.providerId
+ : undefined,
+ model: member.model?.trim() || undefined,
+ effort:
+ member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
+ ? member.effort
+ : undefined,
agentType: prev?.agentType ?? 'general-purpose',
color: prev?.color ?? getMemberColorByName(name),
joinedAt: prev?.joinedAt ?? joinedAt,
@@ -1957,6 +1984,16 @@ export class TeamDataService {
return name;
})(),
role: member.role?.trim() || undefined,
+ workflow: member.workflow?.trim() || undefined,
+ providerId:
+ member.providerId === 'codex' || member.providerId === 'gemini'
+ ? member.providerId
+ : undefined,
+ model: member.model?.trim() || undefined,
+ effort:
+ member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
+ ? member.effort
+ : undefined,
agentType: 'general-purpose',
color: getMemberColorByName(member.name.trim()),
joinedAt,
diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts
index c5368981..92832d0e 100644
--- a/src/main/services/team/TeamMemberResolver.ts
+++ b/src/main/services/team/TeamMemberResolver.ts
@@ -17,6 +17,7 @@ const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
'cross_team_list_targets',
'cross_team_get_outbox',
]);
+const GENERATED_AGENT_ID_PATTERN = /^a[0-9a-f]{16}$/i;
function looksLikeQualifiedExternalRecipient(name: string): boolean {
const trimmed = name.trim();
@@ -51,6 +52,10 @@ function looksLikeCrossTeamToolRecipient(name: string): boolean {
return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(name.trim());
}
+function looksLikeGeneratedAgentId(name: string): boolean {
+ return GENERATED_AGENT_ID_PATTERN.test(name.trim());
+}
+
export class TeamMemberResolver {
resolveMembers(
config: TeamConfig,
@@ -106,13 +111,25 @@ export class TeamMemberResolver {
) {
continue;
}
+ if (!explicitNames.has(trimmed.toLowerCase()) && looksLikeGeneratedAgentId(trimmed)) {
+ continue;
+ }
addName(trimmed);
}
}
const configMemberMap = new Map<
string,
- { agentType?: string; role?: string; workflow?: string; color?: string; cwd?: string }
+ {
+ agentType?: string;
+ role?: string;
+ workflow?: string;
+ providerId?: 'anthropic' | 'codex' | 'gemini';
+ model?: string;
+ effort?: 'low' | 'medium' | 'high';
+ color?: string;
+ cwd?: string;
+ }
>();
if (Array.isArray(config.members)) {
for (const m of config.members) {
@@ -121,6 +138,9 @@ export class TeamMemberResolver {
agentType: m.agentType,
role: m.role,
workflow: m.workflow,
+ providerId: m.providerId,
+ model: m.model,
+ effort: m.effort,
color: m.color,
cwd: m.cwd,
});
@@ -130,7 +150,16 @@ export class TeamMemberResolver {
const metaMemberMap = new Map<
string,
- { agentType?: string; role?: string; workflow?: string; color?: string; removedAt?: number }
+ {
+ agentType?: string;
+ role?: string;
+ workflow?: string;
+ providerId?: 'anthropic' | 'codex' | 'gemini';
+ model?: string;
+ effort?: 'low' | 'medium' | 'high';
+ color?: string;
+ removedAt?: number;
+ }
>();
if (Array.isArray(metaMembers)) {
for (const member of metaMembers) {
@@ -139,6 +168,9 @@ export class TeamMemberResolver {
agentType: member.agentType,
role: member.role,
workflow: member.workflow,
+ providerId: member.providerId,
+ model: member.model,
+ effort: member.effort,
color: member.color,
removedAt: member.removedAt,
});
@@ -193,6 +225,9 @@ export class TeamMemberResolver {
agentType: configMember?.agentType ?? metaMember?.agentType,
role: configMember?.role ?? metaMember?.role,
workflow: configMember?.workflow ?? metaMember?.workflow,
+ providerId: configMember?.providerId ?? metaMember?.providerId,
+ model: configMember?.model ?? metaMember?.model,
+ effort: configMember?.effort ?? metaMember?.effort,
cwd: configMember?.cwd,
removedAt: metaMember?.removedAt,
});
diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts
index 78492e35..7bc612ef 100644
--- a/src/main/services/team/TeamMembersMetaStore.ts
+++ b/src/main/services/team/TeamMembersMetaStore.ts
@@ -24,6 +24,15 @@ function normalizeMember(member: TeamMember): TeamMember | null {
name: trimmedName,
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
+ providerId:
+ member.providerId === 'codex' || member.providerId === 'gemini'
+ ? member.providerId
+ : undefined,
+ model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
+ effort:
+ member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
+ ? member.effort
+ : undefined,
agentType:
typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined,
color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined,
diff --git a/src/main/services/team/TeamMetaStore.ts b/src/main/services/team/TeamMetaStore.ts
index 3cc7aaa6..a8bce4dc 100644
--- a/src/main/services/team/TeamMetaStore.ts
+++ b/src/main/services/team/TeamMetaStore.ts
@@ -18,6 +18,7 @@ export interface TeamMetaFile {
color?: string;
cwd: string;
prompt?: string;
+ providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string;
effort?: string;
skipPermissions?: boolean;
@@ -82,6 +83,12 @@ export class TeamMetaStore {
color: typeof file.color === 'string' ? file.color.trim() || undefined : undefined,
cwd: file.cwd.trim(),
prompt: typeof file.prompt === 'string' ? file.prompt.trim() || undefined : undefined,
+ providerId:
+ file.providerId === 'anthropic' ||
+ file.providerId === 'codex' ||
+ file.providerId === 'gemini'
+ ? file.providerId
+ : undefined,
model: typeof file.model === 'string' ? file.model.trim() || undefined : undefined,
effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined,
skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined,
@@ -101,6 +108,7 @@ export class TeamMetaStore {
color: data.color?.trim() || undefined,
cwd: data.cwd.trim(),
prompt: data.prompt?.trim() || undefined,
+ providerId: data.providerId,
model: data.model?.trim() || undefined,
effort: data.effort?.trim() || undefined,
skipPermissions: data.skipPermissions,
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 3306ad81..9841a248 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -69,6 +69,8 @@ import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamMetaStore } from './TeamMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskReader } from './TeamTaskReader';
+import { applyProviderRuntimeEnv, resolveTeamProviderId } from '../runtime/providerRuntimeEnv';
+import { resolveGeminiRuntimeAuth } from '../runtime/geminiRuntimeAuth';
/**
* Kill a team CLI process using SIGKILL (uncatchable).
@@ -106,6 +108,7 @@ import type {
ToolApprovalRequest,
ToolApprovalSettings,
ToolCallMeta,
+ TeamProviderId,
} from '@shared/types';
const logger = createLogger('Service:TeamProvisioning');
@@ -121,6 +124,9 @@ const LOG_PROGRESS_THROTTLE_MS = 300;
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
const PREFLIGHT_TIMEOUT_MS = 60000;
+const PREFLIGHT_CODEX_TIMEOUT_MS = 20000;
+const PREFLIGHT_GEMINI_TIMEOUT_MS = 15000;
+const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
const FS_MONITOR_POLL_MS = 2000;
@@ -143,18 +149,69 @@ const HANDLED_STREAM_JSON_TYPES = new Set([
'system',
]);
const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.';
-const PREFLIGHT_PING_ARGS = [
- '-p',
- PREFLIGHT_PING_PROMPT,
- '--output-format',
- 'text',
- '--model',
- 'haiku',
- '--max-turns',
- '1',
- '--no-session-persistence',
-] as const;
const PREFLIGHT_EXPECTED = 'PONG';
+const PREFLIGHT_CODEX_MODEL = 'gpt-5.4-mini';
+const PREFLIGHT_GEMINI_MODEL = 'gemini-2.5-flash-lite';
+
+function getPreflightPingModel(providerId: TeamProviderId | undefined): string {
+ switch (resolveTeamProviderId(providerId)) {
+ case 'codex':
+ return PREFLIGHT_CODEX_MODEL;
+ case 'gemini':
+ return PREFLIGHT_GEMINI_MODEL;
+ case 'anthropic':
+ default:
+ return 'haiku';
+ }
+}
+
+function getPreflightPingArgs(providerId: TeamProviderId | undefined): string[] {
+ return [
+ '-p',
+ PREFLIGHT_PING_PROMPT,
+ '--output-format',
+ 'text',
+ '--model',
+ getPreflightPingModel(providerId),
+ '--max-turns',
+ '1',
+ '--no-session-persistence',
+ ];
+}
+
+function getPreflightTimeoutMs(providerId: TeamProviderId | undefined): number {
+ switch (resolveTeamProviderId(providerId)) {
+ case 'codex':
+ return PREFLIGHT_CODEX_TIMEOUT_MS;
+ case 'gemini':
+ return PREFLIGHT_GEMINI_TIMEOUT_MS;
+ case 'anthropic':
+ default:
+ return PREFLIGHT_TIMEOUT_MS;
+ }
+}
+
+function isProbeTimeoutMessage(message: string): boolean {
+ const lower = message.toLowerCase();
+ return (
+ lower.includes('timeout running:') ||
+ lower.includes('timed out') ||
+ lower.includes('did not complete') ||
+ lower.includes('etimedout')
+ );
+}
+
+function getTeamProviderLabel(providerId: TeamProviderId): string {
+ switch (providerId) {
+ case 'codex':
+ return 'Codex';
+ case 'gemini':
+ return 'Gemini';
+ case 'anthropic':
+ default:
+ return 'Anthropic';
+ }
+}
type TeamsBaseLocation = 'configured' | 'default';
@@ -257,6 +314,8 @@ interface ProvisioningRun {
* watchdog defers to retry messages for progress.message (retries are
* more informative than the generic "CLI not responding" stall text). */
lastRetryAt: number;
+ /** Index of the latest api_retry warning block in provisioningOutputParts. */
+ apiRetryWarningIndex: number | null;
/** True after emitApiErrorWarning() fires once — prevents duplicate warnings and pre-complete false positives. */
apiErrorWarningEmitted: boolean;
fsPhase: 'waiting_config' | 'waiting_members' | 'waiting_tasks' | 'all_files_found';
@@ -351,7 +410,12 @@ interface ProvisioningRun {
type LeadActivityState = 'active' | 'idle' | 'offline';
-type ProvisioningAuthSource = 'anthropic_api_key' | 'anthropic_auth_token' | 'none';
+type ProvisioningAuthSource =
+ | 'anthropic_api_key'
+ | 'anthropic_auth_token'
+ | 'codex_runtime'
+ | 'gemini_runtime'
+ | 'none';
interface ProvisioningEnvResolution {
env: NodeJS.ProcessEnv;
@@ -405,6 +469,11 @@ async function ensureCwdExists(cwd: string): Promise {
}
}
+function isMissingCwdSpawnError(message: string): boolean {
+ const lower = message.toLowerCase();
+ return lower.includes('spawn ') && lower.includes(' enoent');
+}
+
/** @deprecated Use wrapAgentBlock from @shared/constants/agentBlocks instead. */
const wrapInAgentBlock = wrapAgentBlock;
@@ -426,10 +495,16 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string {
return members
.map((member) => {
const rolePart = member.role?.trim() ? ` (role: ${member.role.trim()})` : '';
+ const providerPart =
+ member.providerId && member.providerId !== 'anthropic'
+ ? ` [provider: ${member.providerId}]`
+ : '';
+ const modelPart = member.model?.trim() ? ` [model: ${member.model.trim()}]` : '';
+ const effortPart = member.effort ? ` [effort: ${member.effort}]` : '';
const workflowPart = member.workflow?.trim()
? `\n Workflow/instructions:${formatWorkflowBlock(member.workflow, ' ')}`
: '';
- return `- ${member.name}${rolePart}${workflowPart}`;
+ return `- ${member.name}${rolePart}${providerPart}${modelPart}${effortPart}${workflowPart}`;
})
.join('\n');
}
@@ -463,13 +538,21 @@ function buildMemberSpawnPrompt(
leadName: string
): string {
const role = member.role?.trim() || 'team member';
+ const providerLine =
+ member.providerId && member.providerId !== 'anthropic'
+ ? `\nProvider override for this teammate: ${member.providerId}.`
+ : '';
+ const modelLine = member.model?.trim()
+ ? `\nModel override for this teammate: ${member.model.trim()}.`
+ : '';
+ const effortLine = member.effort ? `\nEffort override for this teammate: ${member.effort}.` : '';
const workflowBlock = member.workflow?.trim()
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}`
: '';
const actionModeProtocol = protocols.buildActionModeProtocolText(
protocols.MEMBER_DELEGATE_DESCRIPTION
);
- return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${workflowBlock}
+ return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock}
${getAgentLanguageInstruction()}
Your FIRST action: call MCP tool member_briefing with:
@@ -499,6 +582,16 @@ function buildReconnectMemberSpawnPrompt(
hasTasks: boolean
): string {
const role = member.role?.trim() || 'team member';
+ const providerLine =
+ member.providerId && member.providerId !== 'anthropic'
+ ? `\n Provider override for this teammate: ${member.providerId}.`
+ : '';
+ const modelLine = member.model?.trim()
+ ? `\n Model override for this teammate: ${member.model.trim()}.`
+ : '';
+ const effortLine = member.effort
+ ? `\n Effort override for this teammate: ${member.effort}.`
+ : '';
const workflowBlock = member.workflow?.trim()
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, ' ')}`
: '';
@@ -506,9 +599,15 @@ function buildReconnectMemberSpawnPrompt(
protocols.buildActionModeProtocolText(protocols.MEMBER_DELEGATE_DESCRIPTION),
' '
);
+ const providerArgLine =
+ member.providerId && member.providerId !== 'anthropic'
+ ? ` - provider: "${member.providerId}"\n`
+ : '';
+ const modelArgLine = member.model?.trim() ? ` - model: "${member.model.trim()}"\n` : '';
+ const effortArgLine = member.effort ? ` - effort: "${member.effort}"\n` : '';
return ` For "${member.name}":
- - prompt:
- You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${workflowBlock}
+${providerArgLine}${modelArgLine}${effortArgLine} - prompt:
+ You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock}
${getAgentLanguageInstruction()}
The team has been reconnected after a restart.
@@ -546,7 +645,10 @@ export function buildAddMemberSpawnMessage(
teamName: string,
displayName: string,
leadName: string,
- member: Pick
+ member: Pick<
+ TeamCreateRequest['members'][number],
+ 'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort'
+ >
): string {
const roleHint =
typeof member.role === 'string' && member.role.trim()
@@ -562,15 +664,24 @@ export function buildAddMemberSpawnMessage(
name: member.name,
...(member.role ? { role: member.role } : {}),
...(member.workflow ? { workflow: member.workflow } : {}),
+ ...(member.providerId ? { providerId: member.providerId } : {}),
+ ...(member.model ? { model: member.model } : {}),
+ ...(member.effort ? { effort: member.effort } : {}),
},
displayName,
teamName,
leadName
);
+ const providerPart =
+ member.providerId && member.providerId !== 'anthropic'
+ ? `, provider="${member.providerId}"`
+ : '';
+ const modelPart = member.model?.trim() ? `, model="${member.model.trim()}"` : '';
+ const effortPart = member.effort ? `, effort="${member.effort}"` : '';
return (
`A new teammate "${member.name}"${roleHint} has been added to the team. ` +
- `Please spawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose", and the exact prompt below:${workflowHint}\n\n` +
+ `Please spawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${providerPart}${modelPart}${effortPart}, and the exact prompt below:${workflowHint}\n\n` +
indentMultiline(prompt, ' ')
);
}
@@ -926,15 +1037,18 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
Per-member spawn instructions:
${request.members
- .map(
- (m) => ` For “${m.name}”:
+ .map((m) => {
+ const providerLine =
+ m.providerId && m.providerId !== 'anthropic' ? ` - provider: “${m.providerId}”\n` : '';
+ const modelLine = m.model?.trim() ? ` - model: “${m.model.trim()}”\n` : '';
+ return ` For “${m.name}”:
- name: “${m.name}”
- - prompt:
+${providerLine}${modelLine} - prompt:
${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName)
.split('\n')
.map((line) => ` ${line}`)
- .join('\n')}`
- )
+ .join('\n')}`;
+ })
.join('\n\n')}`;
const persistentContext = buildPersistentLeadContext({
@@ -1212,8 +1326,8 @@ type AuthWarningSource = 'probe' | 'stdout' | 'stderr' | 'assistant' | 'pre-comp
const cachedProbeResults = new Map();
const probeInFlightByKey = new Map>();
-function createProbeCacheKey(cwd: string): string {
- return `${path.resolve(cwd)}::${getClaudeBasePath()}`;
+function createProbeCacheKey(cwd: string, providerId: TeamProviderId | undefined): string {
+ return `${path.resolve(cwd)}::${getClaudeBasePath()}::${resolveTeamProviderId(providerId)}`;
}
function isTransientProbeWarning(warning: string): boolean {
@@ -1228,6 +1342,17 @@ function isTransientProbeWarning(warning: string): boolean {
);
}
+function isBinaryProbeWarning(warning: string): boolean {
+ const lower = warning.toLowerCase();
+ return (
+ (lower.includes('spawn ') && lower.includes(' enoent')) ||
+ lower.includes('eacces') ||
+ lower.includes('enoexec') ||
+ lower.includes('bad cpu type in executable') ||
+ lower.includes('image not found')
+ );
+}
+
interface PendingInboxRelayCandidate {
recipient: string;
sourceMessageId: string;
@@ -2304,8 +2429,8 @@ export class TeamProvisioningService {
async warmup(): Promise {
try {
const cwd = process.cwd();
- if (this.getFreshCachedProbeResult(cwd)) return;
- const result = await this.getCachedOrProbeResult(cwd);
+ if (this.getFreshCachedProbeResult(cwd, 'anthropic')) return;
+ const result = await this.getCachedOrProbeResult(cwd, 'anthropic');
if (!result) return;
logger.info('CLI warmup completed');
} catch (error) {
@@ -2315,32 +2440,26 @@ export class TeamProvisioningService {
async prepareForProvisioning(
cwd?: string,
- opts?: { forceFresh?: boolean }
+ opts?: { forceFresh?: boolean; providerId?: TeamProviderId; providerIds?: TeamProviderId[] }
): Promise {
const targetCwdForValidation = cwd?.trim() || process.cwd();
await this.validatePrepareCwd(targetCwdForValidation);
+ const providerIds = Array.from(
+ new Set(
+ [opts?.providerId, ...(opts?.providerIds ?? [])]
+ .map((providerId) => resolveTeamProviderId(providerId))
+ .filter((providerId): providerId is TeamProviderId => Boolean(providerId))
+ )
+ );
+ if (providerIds.length === 0) {
+ providerIds.push('anthropic');
+ }
// Allow callers (e.g. scheduler warm-up) to bypass the 36h probe cache
if (opts?.forceFresh) {
- this.clearProbeCache(targetCwdForValidation);
- }
-
- const cached = this.getFreshCachedProbeResult(targetCwdForValidation);
- if (cached) {
- const { warning, authSource } = cached;
- const warnings: string[] = [];
- if (warning) warnings.push(warning);
- const isAuthFailure = warning ? this.isAuthFailureWarning(warning, 'probe') : false;
- const ready = !warning || authSource !== 'none' || !isAuthFailure;
- return {
- ready,
- message: ready
- ? warnings.length > 0
- ? 'CLI is ready to launch (see notes)'
- : 'CLI is warmed up and ready to launch'
- : warning || 'CLI is not ready',
- warnings: warnings.length > 0 ? warnings : undefined,
- };
+ for (const providerId of providerIds) {
+ this.clearProbeCache(targetCwdForValidation, providerId);
+ }
}
const targetCwd = cwd?.trim() || process.cwd();
@@ -2349,45 +2468,77 @@ export class TeamProvisioningService {
}
const warnings: string[] = [];
+ const blockingMessages: string[] = [];
- const probeResult = await this.getCachedOrProbeResult(targetCwd);
- if (!probeResult?.claudePath) {
- throw new Error('Claude CLI not found; install it or provide a valid path');
- }
-
- const { authSource } = probeResult;
- if (authSource === 'anthropic_api_key') {
- logger.info('Auth: using explicit ANTHROPIC_API_KEY');
- } else if (authSource === 'anthropic_auth_token') {
- logger.info('Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY');
- }
-
- if (probeResult.warning) {
- const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe');
- if (authSource === 'none' && isAuthFailure) {
- // No auth source + preflight indicates auth failure — block to avoid a confusing hang later.
- return {
- ready: false,
- message: probeResult.warning,
- warnings: warnings.length > 0 ? warnings : undefined,
- };
+ for (const providerId of providerIds) {
+ const cached = this.getFreshCachedProbeResult(targetCwdForValidation, providerId);
+ const probeResult = cached ?? (await this.getCachedOrProbeResult(targetCwd, providerId));
+ if (!probeResult?.claudePath) {
+ throw new Error('Claude CLI not found; install it or provide a valid path');
}
- // Preflight warnings (including timeouts) should not block provisioning.
- warnings.push(probeResult.warning);
+
+ const providerLabel = getTeamProviderLabel(providerId);
+ const { authSource } = probeResult;
+ if (authSource === 'anthropic_api_key') {
+ logger.info(`Auth: using explicit ANTHROPIC_API_KEY for ${providerLabel}`);
+ } else if (authSource === 'anthropic_auth_token') {
+ logger.info(
+ `Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY for ${providerLabel}`
+ );
+ }
+
+ if (!probeResult.warning) {
+ continue;
+ }
+
+ const prefixedWarning =
+ providerIds.length > 1 ? `${providerLabel}: ${probeResult.warning}` : probeResult.warning;
+ const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe');
+ if (
+ (authSource === 'none' ||
+ authSource === 'codex_runtime' ||
+ authSource === 'gemini_runtime') &&
+ isAuthFailure
+ ) {
+ blockingMessages.push(prefixedWarning);
+ } else if (isBinaryProbeWarning(probeResult.warning)) {
+ blockingMessages.push(prefixedWarning);
+ } else {
+ // Preflight warnings (including timeouts) should not block provisioning.
+ warnings.push(prefixedWarning);
+ }
+ }
+
+ if (blockingMessages.length > 0) {
+ return {
+ ready: false,
+ message:
+ blockingMessages.length === 1
+ ? blockingMessages[0]!
+ : 'Some provider runtimes are not ready',
+ warnings: blockingMessages.length > 1 ? blockingMessages : undefined,
+ };
}
return {
ready: true,
message:
- warnings.length > 0
- ? 'CLI is ready to launch (see notes)'
- : 'CLI is warmed up and ready to launch',
+ providerIds.length > 1
+ ? warnings.length > 0
+ ? `Validated ${providerIds.length}/${providerIds.length} provider runtimes (see notes)`
+ : `Validated ${providerIds.length}/${providerIds.length} provider runtimes`
+ : warnings.length > 0
+ ? 'CLI is ready to launch (see notes)'
+ : 'CLI is warmed up and ready to launch',
warnings: warnings.length > 0 ? warnings : undefined,
};
}
- private getFreshCachedProbeResult(cwd: string): CachedProbeResult | null {
- const cacheKey = createProbeCacheKey(cwd);
+ private getFreshCachedProbeResult(
+ cwd: string,
+ providerId: TeamProviderId | undefined
+ ): CachedProbeResult | null {
+ const cacheKey = createProbeCacheKey(cwd, providerId);
const cached = cachedProbeResults.get(cacheKey);
if (!cached) return null;
const ageMs = Date.now() - cached.cachedAtMs;
@@ -2398,8 +2549,8 @@ export class TeamProvisioningService {
return cached;
}
- private clearProbeCache(cwd: string): void {
- cachedProbeResults.delete(createProbeCacheKey(cwd));
+ private clearProbeCache(cwd: string, providerId: TeamProviderId | undefined): void {
+ cachedProbeResults.delete(createProbeCacheKey(cwd, providerId));
}
private async validatePrepareCwd(cwd: string): Promise {
@@ -2414,15 +2565,18 @@ export class TeamProvisioningService {
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
- return;
+ throw new Error(`Working directory does not exist: ${cwd}`);
}
throw error;
}
}
- private async getCachedOrProbeResult(cwd: string): Promise {
- const cacheKey = createProbeCacheKey(cwd);
- const cached = this.getFreshCachedProbeResult(cwd);
+ private async getCachedOrProbeResult(
+ cwd: string,
+ providerId: TeamProviderId | undefined
+ ): Promise {
+ const cacheKey = createProbeCacheKey(cwd, providerId);
+ const cached = this.getFreshCachedProbeResult(cwd, providerId);
if (cached) {
return {
claudePath: cached.claudePath,
@@ -2440,8 +2594,8 @@ export class TeamProvisioningService {
const claudePath = await ClaudeBinaryResolver.resolve();
if (!claudePath) return null;
- const { env, authSource } = await this.buildProvisioningEnv();
- const probe = await this.probeClaudeRuntime(claudePath, cwd, env);
+ const { env, authSource } = await this.buildProvisioningEnv(providerId);
+ const probe = await this.probeClaudeRuntime(claudePath, cwd, env, providerId);
const result = {
claudePath,
authSource,
@@ -2451,7 +2605,8 @@ export class TeamProvisioningService {
const shouldCache =
!probe.warning ||
(!this.isAuthFailureWarning(probe.warning, 'probe') &&
- !isTransientProbeWarning(probe.warning));
+ !isTransientProbeWarning(probe.warning) &&
+ !isBinaryProbeWarning(probe.warning));
if (shouldCache) {
cachedProbeResults.set(cacheKey, { cacheKey, ...result, cachedAtMs: Date.now() });
@@ -2480,8 +2635,14 @@ export class TeamProvisioningService {
lower.includes('missing api key') ||
lower.includes('invalid api key') ||
lower.includes('authentication failed') ||
+ lower.includes('not configured for runtime use') ||
+ lower.includes('set gemini_api_key') ||
+ lower.includes('google adc credentials') ||
+ lower.includes('google_cloud_project') ||
+ lower.includes('codex provider is not authenticated') ||
lower.includes('run `claude auth login`') ||
- lower.includes('claude auth login');
+ lower.includes('claude auth login') ||
+ lower.includes('claude-multimodel auth login');
if (hasExplicitCliAuthSignal) {
return true;
@@ -2515,6 +2676,56 @@ export class TeamProvisioningService {
return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
}
+ private normalizeApiRetryErrorMessage(text: string): string {
+ const sanitized = this.sanitizeCliSnippet(text).trim();
+ if (!sanitized) {
+ return sanitized;
+ }
+
+ const jsonMatch = sanitized.match(/^\d{3}\s+(\{[\s\S]*\})$/);
+ const jsonCandidate = jsonMatch?.[1] ?? (sanitized.startsWith('{') ? sanitized : null);
+ if (jsonCandidate) {
+ try {
+ const parsed = JSON.parse(jsonCandidate) as {
+ error?: { message?: unknown };
+ message?: unknown;
+ };
+ const nestedMessage =
+ typeof parsed.error?.message === 'string'
+ ? parsed.error.message
+ : typeof parsed.message === 'string'
+ ? parsed.message
+ : null;
+ if (nestedMessage) {
+ return this.normalizeApiRetryErrorMessage(nestedMessage);
+ }
+ } catch {
+ // Fall through to raw sanitized text.
+ }
+ }
+
+ return sanitized
+ .replace(/^gemini cli backend error:\s*/i, '')
+ .replace(/^gemini api backend error:\s*/i, '')
+ .replace(/^api error:\s*\d+\s*/i, '')
+ .trim();
+ }
+
+ private isQuotaRetryMessage(text: string | undefined): boolean {
+ const lower = (text ?? '').toLowerCase();
+ return (
+ lower.includes('quota will reset after') ||
+ lower.includes('exhausted your capacity on this model') ||
+ lower.includes('resource exhausted') ||
+ lower.includes('rate limit') ||
+ lower.includes('rate_limit')
+ );
+ }
+
+ private toMarkdownCodeSafe(text: string): string {
+ return this.sanitizeCliSnippet(text).replace(/```/g, '``\\`');
+ }
+
private extractApiErrorSnippet(text: string): string | null {
const match = /api error:\s*\d{3}\b/i.exec(text) ?? /invalid_request_error/i.exec(text);
if (match?.index === undefined) return null;
@@ -3114,6 +3325,7 @@ export class TeamProvisioningService {
stallWarningIndex: null,
preStallMessage: null,
lastRetryAt: 0,
+ apiRetryWarningIndex: null,
apiErrorWarningEmitted: false,
waitingTasksSince: null,
provisioningComplete: false,
@@ -3162,7 +3374,7 @@ export class TeamProvisioningService {
const prompt = buildProvisioningPrompt(request);
let child: ReturnType;
- const { env: shellEnv } = await this.buildProvisioningEnv();
+ const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId);
let mcpConfigPath: string;
try {
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
@@ -3207,6 +3419,7 @@ export class TeamProvisioningService {
color: request.color,
cwd: request.cwd,
prompt: request.prompt,
+ providerId: request.providerId,
model: request.model,
effort: request.effort,
skipPermissions: request.skipPermissions,
@@ -3510,6 +3723,7 @@ export class TeamProvisioningService {
teamName: request.teamName,
members: expectedMemberSpecs,
cwd: request.cwd,
+ providerId: request.providerId,
skipPermissions: request.skipPermissions,
};
@@ -3557,6 +3771,7 @@ export class TeamProvisioningService {
stallWarningIndex: null,
preStallMessage: null,
lastRetryAt: 0,
+ apiRetryWarningIndex: null,
apiErrorWarningEmitted: false,
waitingTasksSince: null,
provisioningComplete: false,
@@ -3627,7 +3842,7 @@ export class TeamProvisioningService {
Boolean(previousSessionId)
);
let child: ReturnType;
- const { env: shellEnv } = await this.buildProvisioningEnv();
+ const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId);
let mcpConfigPath: string;
try {
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
@@ -4674,6 +4889,13 @@ export class TeamProvisioningService {
const inp = input as Record;
const teamName = typeof inp.team_name === 'string' ? inp.team_name.trim() : '';
const memberName = typeof inp.name === 'string' ? inp.name.trim() : '';
+ if (teamName && !memberName) {
+ logger.warn(
+ `[captureTeamSpawnEvents] Agent call for team "${run.teamName}" is missing name — ` +
+ `runtime will spawn an ephemeral subagent instead of a persistent teammate`
+ );
+ continue;
+ }
if (!memberName) continue;
if (!teamName) {
logger.warn(
@@ -5577,21 +5799,45 @@ export class TeamProvisioningService {
const errorStatus = typeof msg.error_status === 'number' ? msg.error_status : undefined;
const errorLabel = typeof msg.error === 'string' ? msg.error.replace(/_/g, ' ') : undefined;
const retryDelay = typeof msg.retry_delay_ms === 'number' ? msg.retry_delay_ms : undefined;
+ const errorMessage =
+ typeof msg.error_message === 'string' && msg.error_message.trim().length > 0
+ ? this.normalizeApiRetryErrorMessage(msg.error_message.trim())
+ : undefined;
+ const looksLikeQuotaRetry =
+ errorLabel === 'rate limit' || this.isQuotaRetryMessage(errorMessage);
- // Use CLI's own error label (e.g. "rate limit") with status code
- const statusLabel = errorLabel
- ? `${errorLabel}${errorStatus ? ` (${errorStatus})` : ''}`
- : `error ${errorStatus ?? 'unknown'}`;
+ // Use a human label for known quota/rate-limit retries instead of a misleading 500 bucket.
+ const statusLabel = looksLikeQuotaRetry
+ ? 'rate limited'
+ : errorLabel
+ ? `${errorLabel}${errorStatus ? ` (${errorStatus})` : ''}`
+ : `error ${errorStatus ?? 'unknown'}`;
const delayLabel = retryDelay ? ` — next retry in ${Math.round(retryDelay / 1000)}s` : '';
- const retryText = `API retry ${attempt}/${maxRetries}: ${statusLabel}${delayLabel}`;
+ const retryText = `API retry ${attempt}/${maxRetries}: ${statusLabel}${
+ errorMessage ? ` — ${errorMessage}` : ''
+ }${delayLabel}`;
if (!run.provisioningComplete) {
+ const warningText = errorMessage
+ ? `**API retry ${attempt}/${maxRetries}: ${statusLabel}**\n\n\`\`\`\n${this.toMarkdownCodeSafe(
+ errorMessage
+ )}\n\`\`\`\n\n${retryDelay ? `Next retry in ${Math.round(retryDelay / 1000)}s.` : 'Retrying...'}`
+ : `**API retry ${attempt}/${maxRetries}: ${statusLabel}**\n\n${
+ retryDelay ? `Next retry in ${Math.round(retryDelay / 1000)}s.` : 'Retrying...'
+ }`;
+ if (run.apiRetryWarningIndex != null) {
+ run.provisioningOutputParts[run.apiRetryWarningIndex] = warningText;
+ } else {
+ run.apiRetryWarningIndex = run.provisioningOutputParts.length;
+ run.provisioningOutputParts.push(warningText);
+ }
run.lastRetryAt = Date.now();
run.progress = {
...run.progress,
updatedAt: nowIso(),
message: retryText,
messageSeverity: 'error' as const,
+ assistantOutput: run.provisioningOutputParts.join('\n\n'),
};
run.onProgress(run.progress);
}
@@ -7563,7 +7809,9 @@ export class TeamProvisioningService {
}
}
- private async buildProvisioningEnv(): Promise {
+ private async buildProvisioningEnv(
+ providerId: TeamProviderId | undefined = 'anthropic'
+ ): Promise {
const shellEnv = await resolveInteractiveShellEnv();
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
// correctly on Windows. Prefer it over process.env which may be garbled.
@@ -7605,6 +7853,7 @@ export class TeamProvisioningService {
: {}),
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
};
+ applyProviderRuntimeEnv(env, providerId);
const controlApiBaseUrl = await this.resolveControlApiBaseUrl();
if (controlApiBaseUrl) {
@@ -7631,6 +7880,14 @@ export class TeamProvisioningService {
env.XDG_STATE_HOME = xdgStateHome;
}
+ if (resolveTeamProviderId(providerId) === 'codex') {
+ return { env, authSource: 'codex_runtime' };
+ }
+
+ if (resolveTeamProviderId(providerId) === 'gemini') {
+ return { env, authSource: 'gemini_runtime' };
+ }
+
// 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly
if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) {
return { env, authSource: 'anthropic_api_key' };
@@ -8300,6 +8557,15 @@ export class TeamProvisioningService {
name: member.name.trim(),
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
+ providerId:
+ member.providerId === 'codex' || member.providerId === 'gemini'
+ ? member.providerId
+ : undefined,
+ model: member.model?.trim() || undefined,
+ effort:
+ member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
+ ? member.effort
+ : undefined,
agentType: 'general-purpose',
color: getMemberColorByName(member.name.trim()),
joinedAt,
@@ -8337,14 +8603,27 @@ export class TeamProvisioningService {
const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined;
const workflow =
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined;
+ const providerId =
+ member.providerId === 'codex' || member.providerId === 'gemini'
+ ? member.providerId
+ : undefined;
+ const model =
+ typeof member.model === 'string' ? member.model.trim() || undefined : undefined;
+ const effort =
+ member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
+ ? member.effort
+ : undefined;
const prev = byName.get(name);
if (!prev) {
- byName.set(name, { name, role, workflow });
+ byName.set(name, { name, role, workflow, providerId, model, effort });
} else {
byName.set(name, {
...prev,
role: prev.role || role,
workflow: prev.workflow || workflow,
+ providerId: prev.providerId || providerId,
+ model: prev.model || model,
+ effort: prev.effort || effort,
});
}
}
@@ -8392,8 +8671,35 @@ export class TeamProvisioningService {
return !inboxNameSetLower.has(match[1].toLowerCase());
});
if (inboxNames.length > 0) {
- const members = inboxNames.map((name) => ({ name }));
- return { members, source: 'inboxes' };
+ const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw);
+ const configMembersByName = new Map(
+ configMembers.map((member) => [member.name.toLowerCase(), member] as const)
+ );
+ const members = inboxNames.map((name) => {
+ const configMember = configMembersByName.get(name.toLowerCase());
+ return {
+ name,
+ role: configMember?.role,
+ workflow: configMember?.workflow,
+ providerId: configMember?.providerId,
+ model: configMember?.model,
+ effort: configMember?.effort,
+ };
+ });
+ const memberOverridesUsed = members.some(
+ (member) => member.providerId || member.model || member.effort
+ );
+ return {
+ members,
+ source: 'inboxes',
+ ...(memberOverridesUsed
+ ? {
+ warning:
+ 'Launch roster was recovered from inboxes and merged with config.json provider/model/effort overrides. ' +
+ 'Multimodel reconnect is best-effort in this fallback path.',
+ }
+ : {}),
+ };
}
} catch (error) {
logger.warn(
@@ -8439,7 +8745,17 @@ export class TeamProvisioningService {
configRaw: string
): TeamCreateRequest['members'] {
try {
- const parsed = JSON.parse(configRaw) as { members?: { name?: string; agentType?: string }[] };
+ const parsed = JSON.parse(configRaw) as {
+ members?: {
+ name?: string;
+ role?: string;
+ workflow?: string;
+ agentType?: string;
+ provider?: string;
+ model?: string;
+ effort?: string;
+ }[];
+ };
if (!Array.isArray(parsed.members)) {
return [];
}
@@ -8450,7 +8766,21 @@ export class TeamProvisioningService {
if (!member || isLeadMember(member) || lower === 'user') continue;
const name = rawName;
if (!name) continue;
- byName.set(name, { name });
+ byName.set(name, {
+ name,
+ role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
+ workflow:
+ typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
+ providerId:
+ member.provider === 'codex' || member.provider === 'gemini'
+ ? member.provider
+ : undefined,
+ model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
+ effort:
+ member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
+ ? member.effort
+ : undefined,
+ });
}
// Defense: ignore CLI auto-suffixed duplicates (alice-2) when base name exists.
const allNames = Array.from(byName.keys());
@@ -8478,8 +8808,50 @@ export class TeamProvisioningService {
private async probeClaudeRuntime(
claudePath: string,
cwd: string,
- env: NodeJS.ProcessEnv
+ env: NodeJS.ProcessEnv,
+ providerId: TeamProviderId | undefined = 'anthropic'
): Promise<{ warning?: string }> {
+ const resolvedProviderId = resolveTeamProviderId(providerId);
+ try {
+ const versionProbe = await this.spawnProbe(
+ claudePath,
+ ['--version'],
+ cwd,
+ env,
+ PREFLIGHT_BINARY_TIMEOUT_MS
+ );
+ if (versionProbe.exitCode !== 0) {
+ const errorText =
+ buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) ||
+ `Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`;
+ return {
+ warning: `Claude CLI binary failed to start correctly. Details: ${errorText}`,
+ };
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ if (isMissingCwdSpawnError(message)) {
+ return {
+ warning: `Working directory does not exist: ${cwd}`,
+ };
+ }
+ return {
+ warning: `Claude CLI binary failed to start. Details: ${message}`,
+ };
+ }
+
+ if (resolvedProviderId === 'gemini') {
+ const authState = await resolveGeminiRuntimeAuth(env);
+ if (authState.authenticated) {
+ return {};
+ }
+ return {
+ warning:
+ authState.statusMessage ??
+ 'Gemini provider is not configured for runtime use. Set GEMINI_API_KEY or Google ADC credentials (plus GOOGLE_CLOUD_PROJECT when needed) and retry.',
+ };
+ }
+
// Stage 1: verify binary works (awaited first for clearer errors)
// Important: keep this sequential with Stage 2 to avoid auth/credential-store races
// when multiple `claude` processes start simultaneously (most visible on Windows).
@@ -8503,10 +8875,10 @@ export class TeamProvisioningService {
try {
pingProbe = await this.spawnProbe(
claudePath,
- [...PREFLIGHT_PING_ARGS],
+ getPreflightPingArgs(providerId),
cwd,
env,
- PREFLIGHT_TIMEOUT_MS,
+ getPreflightTimeoutMs(providerId),
{
resolveOnOutputMatch: ({ stdout, stderr }) => {
const combined = `${stdout}\n${stderr}`.trim();
@@ -8516,7 +8888,7 @@ export class TeamProvisioningService {
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
- if (attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
+ if (!isProbeTimeoutMessage(message) && attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
logger.warn(
`Preflight ping failed (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
`retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms: ${message}`
@@ -8532,12 +8904,7 @@ export class TeamProvisioningService {
}
const combinedOutput = buildCombinedLogs(pingProbe.stdout, pingProbe.stderr);
- const lowerOutput = combinedOutput.toLowerCase();
- const isAuthFailure =
- lowerOutput.includes('not logged in') ||
- lowerOutput.includes('please run /login') ||
- lowerOutput.includes('missing api key') ||
- lowerOutput.includes('invalid api key');
+ const isAuthFailure = this.isAuthFailureWarning(combinedOutput, 'probe');
if (isAuthFailure && attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
logger.warn(
@@ -8550,10 +8917,14 @@ export class TeamProvisioningService {
if (isAuthFailure || pingProbe.exitCode !== 0) {
const hint = isAuthFailure
- ? 'Claude CLI `-p` mode is not authenticated. ' +
- 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' +
- 'For automation/headless use, set ANTHROPIC_API_KEY.' +
- (attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
+ ? resolvedProviderId === 'codex'
+ ? 'Codex provider is not authenticated for `-p` mode. ' +
+ 'Run `claude-multimodel auth login --provider codex` and retry.' +
+ (attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
+ : 'Claude CLI `-p` mode is not authenticated. ' +
+ 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' +
+ 'For automation/headless use, set ANTHROPIC_API_KEY.' +
+ (attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
return { warning: hint };
}
@@ -8591,7 +8962,7 @@ export class TeamProvisioningService {
return this.helpOutputCache;
}
const targetCwd = cwd ?? process.cwd();
- const probeResult = await this.getCachedOrProbeResult(targetCwd);
+ const probeResult = await this.getCachedOrProbeResult(targetCwd, 'anthropic');
if (!probeResult?.claudePath) {
throw new Error('Claude CLI not found');
}
diff --git a/src/main/services/team/memberUpdateNotifications.ts b/src/main/services/team/memberUpdateNotifications.ts
new file mode 100644
index 00000000..c9cbbf77
--- /dev/null
+++ b/src/main/services/team/memberUpdateNotifications.ts
@@ -0,0 +1,154 @@
+export type MemberDiffInput = {
+ name: string;
+ role?: string;
+ workflow?: string;
+ providerId?: 'anthropic' | 'codex' | 'gemini';
+ model?: string;
+ removedAt?: number | string | null;
+};
+
+export type ReplaceMembersDiff = {
+ added: Array<{
+ name: string;
+ role?: string;
+ workflow?: string;
+ providerId?: 'anthropic' | 'codex' | 'gemini';
+ model?: string;
+ }>;
+ removed: string[];
+ updated: Array<{
+ name: string;
+ changes: string[];
+ }>;
+};
+
+function normalizeOptionalText(value: string | undefined): string | undefined {
+ const normalized = value?.trim();
+ return normalized ? normalized : undefined;
+}
+
+function describeRoleChange(
+ previousRole: string | undefined,
+ nextRole: string | undefined
+): string | null {
+ if (previousRole === nextRole) {
+ return null;
+ }
+ if (previousRole && nextRole) {
+ return `role changed from "${previousRole}" to "${nextRole}"`;
+ }
+ if (nextRole) {
+ return `role set to "${nextRole}"`;
+ }
+ return 'role cleared';
+}
+
+function describeWorkflowChange(
+ previousWorkflow: string | undefined,
+ nextWorkflow: string | undefined
+): string | null {
+ if (previousWorkflow === nextWorkflow) {
+ return null;
+ }
+ if (previousWorkflow && nextWorkflow) {
+ return 'workflow instructions were updated';
+ }
+ if (nextWorkflow) {
+ return 'workflow instructions were added';
+ }
+ return 'workflow instructions were cleared';
+}
+
+export function buildReplaceMembersDiff(
+ previousMembers: MemberDiffInput[],
+ nextMembers: Array<{
+ name: string;
+ role?: string;
+ workflow?: string;
+ providerId?: 'anthropic' | 'codex' | 'gemini';
+ model?: string;
+ }>
+): ReplaceMembersDiff {
+ const previousByName = new Map(
+ previousMembers
+ .filter((member) => !member.removedAt && member.name.trim().toLowerCase() !== 'team-lead')
+ .map((member) => [
+ member.name.trim().toLowerCase(),
+ {
+ name: member.name.trim(),
+ role: normalizeOptionalText(member.role),
+ workflow: normalizeOptionalText(member.workflow),
+ providerId: member.providerId,
+ model: normalizeOptionalText(member.model),
+ },
+ ])
+ );
+ const nextByName = new Map(
+ nextMembers
+ .filter((member) => member.name.trim().toLowerCase() !== 'team-lead')
+ .map((member) => [
+ member.name.trim().toLowerCase(),
+ {
+ name: member.name.trim(),
+ role: normalizeOptionalText(member.role),
+ workflow: normalizeOptionalText(member.workflow),
+ providerId: member.providerId,
+ model: normalizeOptionalText(member.model),
+ },
+ ])
+ );
+
+ const added = Array.from(nextByName.entries())
+ .filter(([name]) => !previousByName.has(name))
+ .map(([, member]) => member);
+
+ const removed = Array.from(previousByName.entries())
+ .filter(([name]) => !nextByName.has(name))
+ .map(([, member]) => member.name)
+ .sort((a, b) => a.localeCompare(b));
+
+ const updated = Array.from(nextByName.entries())
+ .flatMap(([name, nextMember]) => {
+ const previousMember = previousByName.get(name);
+ if (!previousMember) {
+ return [];
+ }
+ const changes = [
+ describeRoleChange(previousMember.role, nextMember.role),
+ describeWorkflowChange(previousMember.workflow, nextMember.workflow),
+ ].filter((value): value is string => value !== null);
+ if (changes.length === 0) {
+ return [];
+ }
+ return [{ name: nextMember.name, changes }];
+ })
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ return { added, removed, updated };
+}
+
+export function buildReplaceMembersSummaryMessage(diff: ReplaceMembersDiff): string | null {
+ const lines: string[] = [];
+
+ for (const name of diff.removed) {
+ lines.push(
+ `- Teammate "${name}" was removed from the team. Stop assigning them new work and reassign any active tasks if needed.`
+ );
+ }
+
+ for (const update of diff.updated) {
+ lines.push(
+ `- Teammate "${update.name}" was updated: ${update.changes.join('; ')}. Please send them refreshed instructions so their live behavior matches the new config.`
+ );
+ }
+
+ if (lines.length === 0) {
+ return null;
+ }
+
+ return (
+ 'The user updated the live team roster.\n' +
+ 'Apply these changes to the running team now:\n' +
+ lines.join('\n')
+ );
+}
diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts
index d24b976c..0c74c587 100644
--- a/src/renderer/api/httpClient.ts
+++ b/src/renderer/api/httpClient.ts
@@ -702,7 +702,11 @@ export class HttpAPIClient implements ElectronAPI {
deleteDraft: async (_teamName: string): Promise => {
throw new Error('Draft team deletion is not available in browser mode');
},
- prepareProvisioning: async (_cwd?: string): Promise => {
+ prepareProvisioning: async (
+ _cwd?: string,
+ _providerId?: TeamLaunchRequest['providerId'],
+ _providerIds?: TeamLaunchRequest['providerId'][]
+ ): Promise => {
throw new Error('Team provisioning is not available in browser mode');
},
createTeam: async (_request: TeamCreateRequest): Promise => {
@@ -1057,6 +1061,11 @@ export class HttpAPIClient implements ElectronAPI {
cliInstaller: CliInstallerAPI = {
getStatus: async () => ({
+ flavor: 'claude',
+ displayName: 'Claude CLI',
+ supportsSelfUpdate: true,
+ showVersionDetails: true,
+ showBinaryPath: true,
installed: false,
installedVersion: null,
binaryPath: null,
@@ -1064,6 +1073,7 @@ export class HttpAPIClient implements ElectronAPI {
updateAvailable: false,
authLoggedIn: false,
authMethod: null,
+ providers: [],
}),
install: async (): Promise => {
console.warn('[HttpAPIClient] CLI installer not available in browser mode');
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx
index da7dec5a..89369941 100644
--- a/src/renderer/components/team/TeamDetailView.tsx
+++ b/src/renderer/components/team/TeamDetailView.tsx
@@ -135,6 +135,9 @@ function areResolvedMembersEqual(
prevMember.agentType !== nextMember.agentType ||
prevMember.role !== nextMember.role ||
prevMember.workflow !== nextMember.workflow ||
+ prevMember.providerId !== nextMember.providerId ||
+ prevMember.model !== nextMember.model ||
+ prevMember.effort !== nextMember.effort ||
prevMember.cwd !== nextMember.cwd ||
prevMember.gitBranch !== nextMember.gitBranch ||
prevMember.removedAt !== nextMember.removedAt
@@ -1616,6 +1619,7 @@ export const TeamDetailView = ({
isTeamAlive={data.isAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivityByTeam[teamName]}
+ launchParams={launchParams}
onMemberClick={setSelectedMember}
onSendMessage={(member) => {
setSendDialogRecipient(member.name);
@@ -1976,6 +1980,7 @@ export const TeamDetailView = ({
currentDescription={data.config.description ?? ''}
currentColor={data.config.color ?? ''}
currentMembers={data.members.filter((m) => !isLeadMember(m))}
+ isTeamAlive={data.isAlive && !isTeamProvisioning}
projectPath={data.config.projectPath}
onClose={() => setEditDialogOpen(false)}
onSaved={() => void selectTeam(teamName)}
@@ -1998,6 +2003,9 @@ export const TeamDetailView = ({
name: entry.name,
role: entry.role,
workflow: entry.workflow,
+ providerId: entry.providerId,
+ model: entry.model,
+ effort: entry.effort,
});
}
setAddMemberDialogOpen(false);
diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx
index 3fab05b1..26eb4187 100644
--- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx
+++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx
@@ -19,11 +19,15 @@ import {
import { Loader2 } from 'lucide-react';
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
+import type { EffortLevel, TeamProviderId } from '@shared/types';
export interface AddMemberEntry {
name: string;
role?: string;
workflow?: string;
+ providerId?: TeamProviderId;
+ model?: string;
+ effort?: EffortLevel;
}
interface AddMemberDialogProps {
@@ -116,6 +120,9 @@ export const AddMemberDialog = ({
name: m.name,
role: m.role,
workflow: m.workflow,
+ providerId: m.providerId,
+ model: m.model,
+ effort: m.effort,
}))
);
};
diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
index 1d2d7550..a6689fb0 100644
--- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
@@ -1,14 +1,15 @@
-import React, { useEffect, useMemo, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import {
buildMemberDraftColorMap,
buildMemberDraftSuggestions,
buildMembersFromDrafts,
+ clearMemberModelOverrides,
createMemberDraft,
- MembersEditorSection,
validateMemberNameInline,
} from '@renderer/components/team/members/MembersEditorSection';
+import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
@@ -36,12 +37,19 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { AdvancedCliSection } from './AdvancedCliSection';
-import { EffortLevelSelector } from './EffortLevelSelector';
-import { LimitContextCheckbox } from './LimitContextCheckbox';
import { OptionalSettingsSection } from './OptionalSettingsSection';
+import {
+ createInitialProviderChecks,
+ failIncompleteProviderChecks,
+ getProvisioningFailureHint,
+ ProvisioningProviderStatusList,
+ shouldHideProvisioningProviderStatusList,
+ updateProviderCheck,
+ type ProvisioningProviderCheck,
+} from './ProvisioningProviderStatusList';
import { ProjectPathSelector } from './ProjectPathSelector';
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
-import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
+import { computeEffectiveTeamModel } from './TeamModelSelector';
import { getNextSuggestedTeamName } from './teamNameSets';
const TEAM_COLOR_NAMES = [
@@ -59,10 +67,45 @@ import type {
EffortLevel,
Project,
TeamCreateRequest,
+ TeamProviderId,
TeamProvisioningMemberInput,
TeamProvisioningPrepareResult,
} from '@shared/types';
+function getStoredTeamProvider(): TeamProviderId {
+ const stored = localStorage.getItem('team:lastSelectedProvider');
+ return stored === 'codex' || stored === 'gemini' ? stored : 'anthropic';
+}
+
+function getStoredTeamModel(providerId: TeamProviderId): string {
+ const stored = localStorage.getItem(`team:lastSelectedModel:${providerId}`);
+ if (stored === null) {
+ return providerId === 'anthropic' ? 'opus' : '';
+ }
+ return stored === '__default__' ? '' : stored;
+}
+
+function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean {
+ const normalized = normalizePath(projectPath ?? '').toLowerCase();
+ return (
+ normalized.includes('rendered_mcp_') ||
+ normalized.includes('rendered_mcp_config') ||
+ normalized.includes('/portable-mcp-live')
+ );
+}
+
+function getProviderLabel(providerId: TeamProviderId): string {
+ switch (providerId) {
+ case 'codex':
+ return 'Codex';
+ case 'gemini':
+ return 'Gemini';
+ case 'anthropic':
+ default:
+ return 'Anthropic';
+ }
+}
+
export interface TeamCopyData {
teamName: string;
description?: string;
@@ -230,6 +273,8 @@ export const CreateTeamDialog = ({
setTeamName,
members,
setMembers,
+ syncModelsWithLead,
+ setSyncModelsWithLead,
cwdMode,
setCwdMode,
selectedProjectPath,
@@ -258,6 +303,7 @@ export const CreateTeamDialog = ({
const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
const [prepareMessage, setPrepareMessage] = useState(null);
const [prepareWarnings, setPrepareWarnings] = useState([]);
+ const [prepareChecks, setPrepareChecks] = useState([]);
const prepareRequestSeqRef = useRef(0);
const lastAutoDescriptionRef = useRef(null);
const [fieldErrors, setFieldErrors] = useState<{
@@ -267,11 +313,11 @@ export const CreateTeamDialog = ({
}>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [conflictDismissed, setConflictDismissed] = useState(false);
- const [selectedModel, setSelectedModelRaw] = useState(() => {
- const stored = localStorage.getItem('team:lastSelectedModel');
- if (stored === null) return 'opus';
- return stored === '__default__' ? '' : stored;
- });
+ const [selectedProviderId, setSelectedProviderIdRaw] =
+ useState(getStoredTeamProvider);
+ const [selectedModel, setSelectedModelRaw] = useState(() =>
+ getStoredTeamModel(getStoredTeamProvider())
+ );
const [limitContext, setLimitContextRaw] = useState(
() => localStorage.getItem('team:lastLimitContext') === 'true'
);
@@ -289,6 +335,17 @@ export const CreateTeamDialog = ({
const [worktreeName, setWorktreeNameRaw] = useState('');
const [customArgs, setCustomArgsRaw] = useState('');
+ useEffect(() => {
+ const legacyTeamModel = localStorage.getItem('team:lastSelectedModel');
+ if (
+ legacyTeamModel != null &&
+ localStorage.getItem('team:lastSelectedModel:anthropic') == null
+ ) {
+ localStorage.setItem('team:lastSelectedModel:anthropic', legacyTeamModel);
+ }
+ localStorage.removeItem('team:lastSelectedModel');
+ }, []);
+
// Re-read localStorage when advancedKey changes
useEffect(() => {
const storedEnabled =
@@ -301,7 +358,17 @@ export const CreateTeamDialog = ({
const setSelectedModel = (value: string): void => {
setSelectedModelRaw(value);
- localStorage.setItem('team:lastSelectedModel', value);
+ localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, value);
+ };
+
+ const setSelectedProviderId = (value: TeamProviderId): void => {
+ setSelectedProviderIdRaw(value);
+ localStorage.setItem('team:lastSelectedProvider', value);
+ if (value !== 'anthropic') {
+ setLimitContextRaw(false);
+ localStorage.setItem('team:lastLimitContext', 'false');
+ }
+ setSelectedModelRaw(getStoredTeamModel(value));
};
const setLimitContext = (value: boolean): void => {
@@ -343,6 +410,7 @@ export const CreateTeamDialog = ({
setPrepareState('idle');
setPrepareMessage(null);
setPrepareWarnings([]);
+ setPrepareChecks([]);
setConflictDismissed(false);
};
@@ -371,6 +439,25 @@ export const CreateTeamDialog = ({
}
}, [open, clearProvisioningError, dialogTeamNameKey]);
+ const effectiveMemberDrafts = useMemo(
+ () => (syncModelsWithLead ? members.map(clearMemberModelOverrides) : members),
+ [members, syncModelsWithLead]
+ );
+
+ const selectedMemberProviders = useMemo(() => {
+ if (soloTeam || syncModelsWithLead) {
+ return [selectedProviderId];
+ }
+ return Array.from(
+ new Set([
+ selectedProviderId,
+ ...members.flatMap((member) =>
+ member.providerId === 'codex' || member.providerId === 'gemini' ? [member.providerId] : []
+ ),
+ ])
+ );
+ }, [members, selectedProviderId, soloTeam, syncModelsWithLead]);
+
useEffect(() => {
if (!open || !canCreate || !launchTeam) {
return;
@@ -379,6 +466,7 @@ export const CreateTeamDialog = ({
if (typeof api.teams.prepareProvisioning !== 'function') {
setPrepareState('failed');
setPrepareWarnings([]);
+ setPrepareChecks([]);
setPrepareMessage(
'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
);
@@ -388,6 +476,7 @@ export const CreateTeamDialog = ({
if (!effectiveCwd) {
setPrepareState('idle');
setPrepareWarnings([]);
+ setPrepareChecks([]);
setPrepareMessage('Select a working directory to validate the launch environment.');
return;
}
@@ -395,26 +484,75 @@ export const CreateTeamDialog = ({
let cancelled = false;
const requestSeq = ++prepareRequestSeqRef.current;
setPrepareState('loading');
- setPrepareMessage('Warming up CLI environment...');
+ setPrepareMessage('Checking selected providers...');
setPrepareWarnings([]);
+ setPrepareChecks(createInitialProviderChecks(selectedMemberProviders));
// Defer so file list fetch (triggered by project select) can run first
const timer = setTimeout(() => {
void (async () => {
+ let checks = createInitialProviderChecks(selectedMemberProviders);
+ let anyFailure = false;
+ let anyNotes = false;
+ const collectedWarnings: string[] = [];
+
try {
- const prepResult: TeamProvisioningPrepareResult =
- await api.teams.prepareProvisioning(effectiveCwd);
+ for (const providerId of selectedMemberProviders) {
+ checks = updateProviderCheck(checks, providerId, {
+ status: 'checking',
+ details: [],
+ });
+ if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
+ setPrepareChecks(checks);
+ setPrepareMessage(`Checking ${getProviderLabel(providerId)} runtime...`);
+ }
+
+ const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning(
+ effectiveCwd,
+ providerId,
+ [providerId]
+ );
+ const detailLines = [
+ ...(prepResult.warnings ?? []).filter(Boolean),
+ ...(!prepResult.ready && prepResult.message ? [prepResult.message] : []),
+ ];
+ if (prepResult.warnings?.length) {
+ anyNotes = true;
+ collectedWarnings.push(
+ ...prepResult.warnings.map(
+ (warning) => `${getProviderLabel(providerId)}: ${warning}`
+ )
+ );
+ }
+ if (!prepResult.ready) {
+ anyFailure = true;
+ }
+ checks = updateProviderCheck(checks, providerId, {
+ status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready',
+ details: detailLines,
+ });
+ if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
+ setPrepareChecks(checks);
+ }
+ }
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
- setPrepareState(prepResult.ready ? 'ready' : 'failed');
- setPrepareMessage(prepResult.message);
- setPrepareWarnings(prepResult.warnings ?? []);
+ setPrepareState(anyFailure ? 'failed' : 'ready');
+ setPrepareMessage(
+ anyFailure
+ ? 'Some selected providers need attention.'
+ : anyNotes
+ ? 'Selected providers are ready with notes.'
+ : 'Selected providers are ready.'
+ );
+ setPrepareWarnings(collectedWarnings);
} catch (error) {
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
+ const failureMessage =
+ error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment';
setPrepareState('failed');
setPrepareWarnings([]);
- setPrepareMessage(
- error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'
- );
+ setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage));
+ setPrepareMessage(failureMessage);
}
})();
}, 250);
@@ -423,7 +561,7 @@ export const CreateTeamDialog = ({
cancelled = true;
clearTimeout(timer);
};
- }, [open, canCreate, launchTeam, effectiveCwd]);
+ }, [open, canCreate, launchTeam, effectiveCwd, selectedProviderId, selectedMemberProviders]);
useEffect(() => {
if (!open) {
@@ -446,6 +584,7 @@ export const CreateTeamDialog = ({
// display and select it.
if (
defaultProjectPath &&
+ !isEphemeralRenderedProjectPath(defaultProjectPath) &&
!nextProjects.some((p) => normalizePath(p.path) === defaultProjectPath)
) {
const folderName =
@@ -497,9 +636,15 @@ export const CreateTeamDialog = ({
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
customRole: isCustom ? m.role : '',
workflow: m.workflow,
+ providerId: m.providerId,
+ model: m.model ?? '',
+ effort: m.effort,
});
})
);
+ setSyncModelsWithLead(
+ !initialData.members.some((member) => member.providerId || member.model || member.effort)
+ );
return;
}
@@ -559,7 +704,7 @@ export const CreateTeamDialog = ({
if (selectedProjectPath || projects.length === 0) {
return;
}
- if (defaultProjectPath) {
+ if (defaultProjectPath && !isEphemeralRenderedProjectPath(defaultProjectPath)) {
const match = projects.find((p) => normalizePath(p.path) === defaultProjectPath);
if (match) {
setSelectedProjectPath(match.path);
@@ -569,6 +714,16 @@ export const CreateTeamDialog = ({
setSelectedProjectPath(projects[0].path);
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
+ useEffect(() => {
+ if (!open || cwdMode !== 'project' || !selectedProjectPath) {
+ return;
+ }
+ if (!isEphemeralRenderedProjectPath(selectedProjectPath)) {
+ return;
+ }
+ setSelectedProjectPath('');
+ }, [open, cwdMode, selectedProjectPath, setSelectedProjectPath]);
+
useFileListCacheWarmer(effectiveCwd || null);
const { suggestions: taskSuggestions } = useTaskSuggestions(null);
@@ -587,8 +742,8 @@ export const CreateTeamDialog = ({
);
const effectiveModel = useMemo(
- () => computeEffectiveTeamModel(selectedModel, limitContext),
- [selectedModel, limitContext]
+ () => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId),
+ [selectedModel, limitContext, selectedProviderId]
);
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
@@ -601,9 +756,10 @@ export const CreateTeamDialog = ({
teamName: sanitizedTeamName,
description: description.trim() || undefined,
color: teamColor || undefined,
- members: soloTeam ? [] : buildMembersFromDrafts(members),
+ members: soloTeam ? [] : buildMembersFromDrafts(effectiveMemberDrafts),
cwd: effectiveCwd,
prompt: prompt.trim() || undefined,
+ providerId: selectedProviderId,
model: effectiveModel,
effort: (selectedEffort as EffortLevel) || undefined,
limitContext,
@@ -616,9 +772,10 @@ export const CreateTeamDialog = ({
description,
teamColor,
soloTeam,
- members,
+ effectiveMemberDrafts,
effectiveCwd,
prompt,
+ selectedProviderId,
effectiveModel,
selectedEffort,
limitContext,
@@ -643,23 +800,11 @@ export const CreateTeamDialog = ({
const launchOptionalSummary = useMemo(() => {
const summary: string[] = [];
if (prompt.trim()) summary.push('Lead prompt');
- if (selectedModel) summary.push(`Model: ${selectedModel}`);
- if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
- if (limitContext) summary.push('Limited to 200K context');
if (skipPermissions) summary.push('Auto-approve tools');
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
if (customArgs.trim()) summary.push('Custom CLI args');
return summary;
- }, [
- prompt,
- selectedModel,
- selectedEffort,
- limitContext,
- skipPermissions,
- worktreeEnabled,
- worktreeName,
- customArgs,
- ]);
+ }, [prompt, skipPermissions, worktreeEnabled, worktreeName, customArgs]);
const teamDetailsSummary = useMemo(() => {
const summary: string[] = [];
@@ -668,6 +813,16 @@ export const CreateTeamDialog = ({
return summary;
}, [description, teamColor]);
+ const handleSyncModelsWithLeadChange = useCallback(
+ (checked: boolean): void => {
+ setSyncModelsWithLead(checked);
+ if (checked) {
+ setMembers(members.map(clearMemberModelOverrides));
+ }
+ },
+ [members, setMembers, setSyncModelsWithLead]
+ );
+
const activeError = localError ?? provisioningErrorsByTeam[request.teamName] ?? null;
const canOpenExistingTeam =
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
@@ -822,7 +977,14 @@ export const CreateTeamDialog = ({
{prepareMessage ?? 'Failed to prepare environment'}
- {prepareWarnings.length > 0 ? (
+ {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? (
+
+ ) : null}
+ {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
{prepareWarnings.map((warning) => (
) : null}
- Make sure claude CLI is installed and available
- in PATH, then reopen this dialog.
+ {getProvisioningFailureHint(prepareMessage, prepareChecks)}
@@ -892,9 +1053,9 @@ export const CreateTeamDialog = ({
-
-
- setSoloTeam(checked === true)}
- />
-
-
- {soloTeam && (
-
-
-
- Only the team lead (main process) will be started — no teammates will
- be spawned. Works like a regular Claude session but with access to the task
- board for planning. Saves tokens by avoiding teammate coordination overhead.
- You can add members later from the team settings.
-
-
- )}
+ defaultProviderId={selectedProviderId}
+ inheritedProviderId={selectedProviderId}
+ inheritedModel={selectedModel}
+ inheritedEffort={(selectedEffort as EffortLevel) || undefined}
+ inheritModelSettingsByDefault
+ lockProviderModel={syncModelsWithLead}
+ forceInheritedModelSettings={syncModelsWithLead}
+ modelLockReason="This teammate is synced with the lead model. Turn off sync to set a custom provider, model, or effort."
+ hideMembersContent={soloTeam}
+ providerId={selectedProviderId}
+ model={selectedModel}
+ effort={(selectedEffort as EffortLevel) || undefined}
+ limitContext={limitContext}
+ onProviderChange={setSelectedProviderId}
+ onModelChange={setSelectedModel}
+ onEffortChange={setSelectedEffort}
+ onLimitContextChange={setLimitContext}
+ syncModelsWithTeammates={syncModelsWithLead}
+ onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
+ headerTop={
+
+ setSoloTeam(checked === true)}
+ />
+
}
+ headerBottom={
+ soloTeam ? (
+
+
+
+ Only the team lead (main process) will be started — no teammates will be
+ spawned. Works like a regular Claude session but with access to the task board
+ for planning. Saves tokens by avoiding teammate coordination overhead. You can
+ add members later from the team settings.
+
+
+ ) : null
+ }
/>
@@ -984,7 +1163,7 @@ export const CreateTeamDialog = ({
@@ -1017,29 +1196,11 @@ export const CreateTeamDialog = ({
/>
-
-
-
-
-
-
+
{canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
-
-
-
-
- {prepareMessage ??
- (prepareState === 'idle'
- ? 'Warming up CLI environment...'
- : 'Preparing environment...')}
-
-
- Pre-flight check to catch errors before launch
-
-
+ <>
+
+
+
+
+ {prepareMessage ??
+ (prepareState === 'idle'
+ ? 'Warming up CLI environment...'
+ : 'Preparing environment...')}
+
+
+ Pre-flight check to catch errors before launch
+
+
-
+
+ >
) : null}
{canCreate && launchTeam && prepareState === 'ready' ? (
@@ -1161,7 +1318,8 @@ export const CreateTeamDialog = ({
- {prepareWarnings.length > 0
+ {prepareChecks.some((check) => check.status === 'notes') ||
+ prepareWarnings.length > 0
? 'CLI environment ready (with notes)'
: 'CLI environment ready'}
@@ -1171,7 +1329,8 @@ export const CreateTeamDialog = ({
{prepareMessage}
) : null}
- {prepareWarnings.length > 0 ? (
+
+ {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
{prepareWarnings.map((warning) => (
@@ -1202,12 +1361,7 @@ export const CreateTeamDialog = ({
+ {isTeamAlive ? (
+
+ Provider and model changes are locked while the team is live. Reconnect the team to
+ change them safely.
+
+ ) : null}
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Color picker is a group of buttons, not a single input */}
- Controls how much reasoning Claude invests before responding. Default uses Claude's
- standard behavior.
+ Controls how much reasoning the selected provider invests before responding. Default uses the
+ provider's standard behavior for the selected model.
);
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
index 78ab5652..ed22b46b 100644
--- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
@@ -1,7 +1,15 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
-import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
+import {
+ buildMemberDraftColorMap,
+ buildMemberDraftSuggestions,
+ buildMembersFromDrafts,
+ clearMemberModelOverrides,
+ createMemberDraftsFromInputs,
+ validateMemberNameInline,
+} from '@renderer/components/team/members/MembersEditorSection';
+import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
@@ -21,10 +29,10 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
+import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
+import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
-import { formatAgentRole } from '@renderer/utils/formatAgentRole';
-import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor';
import {
@@ -43,10 +51,20 @@ import { CronScheduleInput } from '../schedule/CronScheduleInput';
import { AdvancedCliSection } from './AdvancedCliSection';
import { EffortLevelSelector } from './EffortLevelSelector';
import { OptionalSettingsSection } from './OptionalSettingsSection';
+import {
+ createInitialProviderChecks,
+ failIncompleteProviderChecks,
+ getProvisioningFailureHint,
+ ProvisioningProviderStatusList,
+ shouldHideProvisioningProviderStatusList,
+ updateProviderCheck,
+ type ProvisioningProviderCheck,
+} from './ProvisioningProviderStatusList';
import { ProjectPathSelector } from './ProjectPathSelector';
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
import type { ActiveTeamRef } from './CreateTeamDialog';
+import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
import type { MentionSuggestion } from '@renderer/types/mention';
import type {
CreateScheduleInput,
@@ -56,6 +74,7 @@ import type {
Schedule,
ScheduleLaunchConfig,
TeamLaunchRequest,
+ TeamProviderId,
TeamProvisioningPrepareResult,
UpdateSchedulePatch,
} from '@shared/types';
@@ -104,6 +123,31 @@ function getLocalTimezone(): string {
}
}
+function getStoredTeamProvider(): TeamProviderId {
+ const stored = localStorage.getItem('team:lastSelectedProvider');
+ return stored === 'codex' || stored === 'gemini' ? stored : 'anthropic';
+}
+
+function getStoredTeamModel(providerId: TeamProviderId): string {
+ const stored = localStorage.getItem(`team:lastSelectedModel:${providerId}`);
+ if (stored === null) {
+ return providerId === 'anthropic' ? 'opus' : '';
+ }
+ return stored === '__default__' ? '' : stored;
+}
+
+function getProviderLabel(providerId: TeamProviderId): string {
+ switch (providerId) {
+ case 'codex':
+ return 'Codex';
+ case 'gemini':
+ return 'Gemini';
+ case 'anthropic':
+ default:
+ return 'Anthropic';
+ }
+}
+
// =============================================================================
// Component
// =============================================================================
@@ -157,11 +201,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const [localError, setLocalError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
- const [selectedModel, setSelectedModelRaw] = useState(() => {
- const stored = localStorage.getItem('team:lastSelectedModel');
- if (stored === null) return 'opus';
- return stored === '__default__' ? '' : stored;
- });
+ const [selectedProviderId, setSelectedProviderIdRaw] =
+ useState(getStoredTeamProvider);
+ const [selectedModel, setSelectedModelRaw] = useState(() =>
+ getStoredTeamModel(getStoredTeamProvider())
+ );
+ const [membersDrafts, setMembersDrafts] = useState([]);
+ const [syncModelsWithLead, setSyncModelsWithLead] = useState(false);
const [skipPermissions, setSkipPermissionsRaw] = useState(
() => localStorage.getItem('team:lastSkipPermissions') !== 'false'
);
@@ -182,7 +228,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
const [prepareMessage, setPrepareMessage] = useState(null);
const [prepareWarnings, setPrepareWarnings] = useState([]);
+ const [prepareChecks, setPrepareChecks] = useState([]);
const prepareRequestSeqRef = useRef(0);
+ const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []);
+ const members = isLaunch ? props.members : storeMembers;
// Advanced CLI section state (with localStorage persistence)
const [worktreeEnabled, setWorktreeEnabledRaw] = useState(
@@ -208,6 +257,24 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const [warmUpMinutes, setWarmUpMinutes] = useState(15);
const [maxTurns, setMaxTurns] = useState(50);
const [maxBudgetUsd, setMaxBudgetUsd] = useState('');
+ const effectiveMemberDrafts = useMemo(
+ () => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts),
+ [membersDrafts, syncModelsWithLead]
+ );
+ const selectedMemberProviders = useMemo(
+ () =>
+ Array.from(
+ new Set([
+ selectedProviderId,
+ ...effectiveMemberDrafts.flatMap((member) =>
+ member.providerId === 'codex' || member.providerId === 'gemini'
+ ? [member.providerId]
+ : []
+ ),
+ ])
+ ),
+ [effectiveMemberDrafts, selectedProviderId]
+ );
// Schedule store actions
const createSchedule = useStore((s) => s.createSchedule);
@@ -234,9 +301,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
localStorage.setItem(`team:lastCustomArgs:${effectiveTeamName}`, value);
};
+ const setSelectedProviderId = (value: TeamProviderId): void => {
+ setSelectedProviderIdRaw(value);
+ localStorage.setItem('team:lastSelectedProvider', value);
+ if (value !== 'anthropic') {
+ setLimitContextRaw(false);
+ localStorage.setItem('team:lastLimitContext', 'false');
+ }
+ setSelectedModelRaw(getStoredTeamModel(value));
+ };
+
const setSelectedModel = (value: string): void => {
setSelectedModelRaw(value);
- localStorage.setItem('team:lastSelectedModel', value);
+ localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, value);
};
const setLimitContext = (value: boolean): void => {
@@ -259,9 +336,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
// ---------------------------------------------------------------------------
useEffect(() => {
+ const legacyTeamModel = localStorage.getItem('team:lastSelectedModel');
+ if (
+ legacyTeamModel != null &&
+ localStorage.getItem('team:lastSelectedModel:anthropic') == null
+ ) {
+ localStorage.setItem('team:lastSelectedModel:anthropic', legacyTeamModel);
+ }
+ localStorage.removeItem('team:lastSelectedModel');
+
for (const suffix of ['lastSelectedModel', 'lastSelectedEffort']) {
const schedKey = `schedule:${suffix}`;
- const teamKey = `team:${suffix}`;
+ const teamKey =
+ suffix === 'lastSelectedModel' ? 'team:lastSelectedModel:anthropic' : `team:${suffix}`;
const schedVal = localStorage.getItem(schedKey);
if (schedVal != null && localStorage.getItem(teamKey) == null) {
localStorage.setItem(teamKey, schedVal);
@@ -280,11 +367,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setPrepareState('idle');
setPrepareMessage(null);
setPrepareWarnings([]);
+ setPrepareChecks([]);
setCwdMode('project');
setSelectedProjectPath('');
setCustomCwd('');
setClearContext(false);
setConflictDismissed(false);
+ setMembersDrafts([]);
+ setSyncModelsWithLead(false);
chipDraft.clearChipDraft();
// Schedule fields
setSelectedTeamName('');
@@ -311,6 +401,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
promptDraft.setValue(schedule.launchConfig.prompt);
setCustomCwd(schedule.launchConfig.cwd);
setCwdMode('custom');
+ setSelectedProviderIdRaw(schedule.launchConfig.providerId ?? 'anthropic');
setSelectedModelRaw(schedule.launchConfig.model ?? '');
setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false);
setSelectedEffortRaw(schedule.launchConfig.effort ?? '');
@@ -326,7 +417,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setCwdMode('project');
setSelectedProjectPath('');
setCustomCwd('');
- setSelectedModelRaw('opus');
+ setSelectedProviderIdRaw(getStoredTeamProvider());
+ setSelectedModelRaw(getStoredTeamModel(getStoredTeamProvider()));
setSelectedEffortRaw('medium');
}
@@ -335,6 +427,62 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isSchedule, schedule?.id]);
+ useEffect(() => {
+ if (!open || !isLaunch) return;
+
+ let cancelled = false;
+ void (async () => {
+ let savedRequest = null;
+ try {
+ savedRequest = effectiveTeamName
+ ? await api.teams.getSavedRequest(effectiveTeamName)
+ : null;
+ } catch {
+ savedRequest = null;
+ }
+ if (cancelled) return;
+
+ const nextProviderId =
+ savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini'
+ ? savedRequest.providerId
+ : 'anthropic';
+ const providerFromSaved = Boolean(savedRequest?.providerId);
+ const nextMembersSource =
+ members.length > 0
+ ? members
+ : savedRequest?.members && savedRequest.members.length > 0
+ ? savedRequest.members
+ : [];
+ const storedEffort = localStorage.getItem('team:lastSelectedEffort');
+
+ setMembersDrafts(createMemberDraftsFromInputs(nextMembersSource));
+ setSyncModelsWithLead(
+ !nextMembersSource.some((member) => member.providerId || member.model || member.effort)
+ );
+ setSelectedProviderIdRaw(providerFromSaved ? nextProviderId : getStoredTeamProvider());
+ setSelectedModelRaw(
+ typeof savedRequest?.model === 'string'
+ ? savedRequest.model
+ : getStoredTeamModel(providerFromSaved ? nextProviderId : getStoredTeamProvider())
+ );
+ setSelectedEffortRaw(
+ savedRequest?.effort ?? (storedEffort === null ? 'medium' : storedEffort)
+ );
+ setLimitContextRaw(
+ savedRequest?.limitContext === true ||
+ localStorage.getItem('team:lastLimitContext') === 'true'
+ );
+ setSkipPermissionsRaw(
+ savedRequest?.skipPermissions ??
+ localStorage.getItem('team:lastSkipPermissions') !== 'false'
+ );
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [open, isLaunch, effectiveTeamName, members]);
+
// ---------------------------------------------------------------------------
// Launch-only effects
// ---------------------------------------------------------------------------
@@ -355,6 +503,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
if (typeof api.teams.prepareProvisioning !== 'function') {
setPrepareState('failed');
setPrepareWarnings([]);
+ setPrepareChecks([]);
setPrepareMessage(
'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
);
@@ -364,6 +513,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
if (!effectiveCwd) {
setPrepareState('idle');
setPrepareWarnings([]);
+ setPrepareChecks([]);
setPrepareMessage('Select a working directory to validate the launch environment.');
return;
}
@@ -371,31 +521,78 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
let cancelled = false;
const requestSeq = ++prepareRequestSeqRef.current;
setPrepareState('loading');
- setPrepareMessage('Warming up CLI environment...');
+ setPrepareMessage('Checking selected providers...');
setPrepareWarnings([]);
+ setPrepareChecks(createInitialProviderChecks(selectedMemberProviders));
void (async () => {
+ let checks = createInitialProviderChecks(selectedMemberProviders);
+ let anyFailure = false;
+ let anyNotes = false;
+ const collectedWarnings: string[] = [];
+
try {
- const prepResult: TeamProvisioningPrepareResult =
- await api.teams.prepareProvisioning(effectiveCwd);
+ for (const providerId of selectedMemberProviders) {
+ checks = updateProviderCheck(checks, providerId, {
+ status: 'checking',
+ details: [],
+ });
+ if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
+ setPrepareChecks(checks);
+ setPrepareMessage(`Checking ${getProviderLabel(providerId)} runtime...`);
+ }
+
+ const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning(
+ effectiveCwd,
+ providerId,
+ [providerId]
+ );
+ const detailLines = [
+ ...(prepResult.warnings ?? []).filter(Boolean),
+ ...(!prepResult.ready && prepResult.message ? [prepResult.message] : []),
+ ];
+ if (prepResult.warnings?.length) {
+ anyNotes = true;
+ collectedWarnings.push(
+ ...prepResult.warnings.map((warning) => `${getProviderLabel(providerId)}: ${warning}`)
+ );
+ }
+ if (!prepResult.ready) {
+ anyFailure = true;
+ }
+ checks = updateProviderCheck(checks, providerId, {
+ status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready',
+ details: detailLines,
+ });
+ if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
+ setPrepareChecks(checks);
+ }
+ }
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
- setPrepareState(prepResult.ready ? 'ready' : 'failed');
- setPrepareMessage(prepResult.message);
- setPrepareWarnings(prepResult.warnings ?? []);
+ setPrepareState(anyFailure ? 'failed' : 'ready');
+ setPrepareMessage(
+ anyFailure
+ ? 'Some selected providers need attention.'
+ : anyNotes
+ ? 'Selected providers are ready with notes.'
+ : 'Selected providers are ready.'
+ );
+ setPrepareWarnings(collectedWarnings);
} catch (error) {
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
+ const failureMessage =
+ error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment';
setPrepareState('failed');
setPrepareWarnings([]);
- setPrepareMessage(
- error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'
- );
+ setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage));
+ setPrepareMessage(failureMessage);
}
})();
return () => {
cancelled = true;
};
- }, [open, isLaunch, effectiveCwd]);
+ }, [open, isLaunch, effectiveCwd, selectedProviderId, selectedMemberProviders]);
// ---------------------------------------------------------------------------
// Shared effects: projects
@@ -490,19 +687,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
// Mention suggestions (shared — from props in launch, from store in schedule)
// ---------------------------------------------------------------------------
- const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []);
- const members = isLaunch ? props.members : storeMembers;
-
- const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
+ const { suggestions: taskSuggestions } = useTaskSuggestions(null);
+ const { suggestions: teamMentionSuggestions } = useTeamSuggestions(null);
+ const memberColorMap = useMemo(
+ () => buildMemberDraftColorMap(membersDrafts, members),
+ [membersDrafts, members]
+ );
const mentionSuggestions = useMemo(
- () =>
- members.map((m) => ({
- id: m.name,
- name: m.name,
- subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
- color: colorMap.get(m.name),
- })),
- [members, colorMap]
+ () => buildMemberDraftSuggestions(membersDrafts, memberColorMap),
+ [memberColorMap, membersDrafts]
);
// ---------------------------------------------------------------------------
@@ -516,21 +709,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
args.push('--verbose', '--setting-sources', 'user,project,local');
args.push('--mcp-config', '', '--disallowedTools', 'TeamDelete,TodoWrite');
if (skipPermissions) args.push('--dangerously-skip-permissions');
- const model = computeEffectiveTeamModel(selectedModel, limitContext);
+ const model = computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId);
if (model) args.push('--model', model);
if (selectedEffort) args.push('--effort', selectedEffort);
if (!clearContext) args.push('--resume', '');
return args;
- }, [isLaunch, skipPermissions, selectedModel, limitContext, selectedEffort, clearContext]);
+ }, [
+ isLaunch,
+ skipPermissions,
+ selectedModel,
+ limitContext,
+ selectedEffort,
+ clearContext,
+ selectedProviderId,
+ ]);
const launchOptionalSummary = useMemo(() => {
if (!isLaunch) return [];
const summary: string[] = [];
if (promptDraft.value.trim()) summary.push('Lead prompt');
+ summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`);
if (selectedModel) summary.push(`Model: ${selectedModel}`);
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
- if (limitContext) summary.push('Limited to 200K context');
+ if (selectedProviderId === 'anthropic' && limitContext) summary.push('Limited to 200K context');
if (skipPermissions) summary.push('Auto-approve tools');
if (clearContext) summary.push('Fresh session');
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
@@ -540,6 +742,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
isLaunch,
promptDraft.value,
selectedModel,
+ selectedProviderId,
selectedEffort,
limitContext,
skipPermissions,
@@ -584,17 +787,39 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setLocalError('Select working directory (cwd)');
return;
}
+ if (
+ isLaunch &&
+ membersDrafts.some(
+ (member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null
+ )
+ ) {
+ setLocalError('Fix member names before launch');
+ return;
+ }
+ if (isLaunch) {
+ const activeNames = membersDrafts
+ .map((member) => member.name.trim().toLowerCase())
+ .filter(Boolean);
+ if (new Set(activeNames).size !== activeNames.length) {
+ setLocalError('Member names must be unique before launch');
+ return;
+ }
+ }
setLocalError(null);
setIsSubmitting(true);
void (async () => {
try {
if (isLaunch) {
+ await api.teams.replaceMembers(effectiveTeamName, {
+ members: buildMembersFromDrafts(effectiveMemberDrafts),
+ });
await props.onLaunch({
teamName: effectiveTeamName,
cwd: effectiveCwd,
prompt: promptDraft.value.trim() || undefined,
- model: computeEffectiveTeamModel(selectedModel, limitContext),
+ providerId: selectedProviderId,
+ model: computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId),
effort: (selectedEffort as EffortLevel) || undefined,
limitContext,
clearContext: clearContext || undefined,
@@ -610,6 +835,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const launchConfig: ScheduleLaunchConfig = {
cwd: effectiveCwd,
prompt: promptDraft.value.trim(),
+ providerId: selectedProviderId,
model: selectedModel || undefined,
effort: (selectedEffort as EffortLevel) || undefined,
skipPermissions,
@@ -748,12 +974,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
- Claude CLI is not installed — launch is blocked
+ CLI environment is not available — launch is blocked
{prepareMessage ?? 'Failed to prepare environment'}
- {prepareWarnings.length > 0 ? (
+ {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? (
+
+ ) : null}
+ {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
{prepareWarnings.map((warning) => (
- Install Claude CLI from the Dashboard, then reopen this dialog.
+ {getProvisioningFailureHint(prepareMessage, prepareChecks)}
-
+ {(prepareMessage ?? '').toLowerCase().includes('spawn ') ||
+ prepareChecks.some((check) =>
+ check.details.some((detail) => detail.toLowerCase().includes('spawn '))
+ ) ? (
+
+ ) : null}
@@ -922,6 +1160,38 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
projectsError={projectsError}
/>
+ {isLaunch ? (
+
+ ) : null}
+
{/* ═══════════════════════════════════════════════════════════════════
Launch: optional settings
Schedule: prompt + execution defaults
@@ -959,22 +1229,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
-
-
-
{prepareState === 'idle' || prepareState === 'loading' ? (
-
-
-
-
- {prepareMessage ??
- (prepareState === 'idle'
- ? 'Warming up CLI environment...'
- : 'Preparing environment...')}
-
-
- Pre-flight check to catch errors before launch
-
-
+ <>
+
+
+
+
+ {prepareMessage ??
+ (prepareState === 'idle'
+ ? 'Warming up CLI environment...'
+ : 'Preparing environment...')}
+
+
+ Pre-flight check to catch errors before launch
+
+
+
-
+
+ >
) : null}
{prepareState === 'ready' ? (
@@ -1165,7 +1424,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
- {prepareWarnings.length > 0
+ {prepareChecks.some((check) => check.status === 'notes') ||
+ prepareWarnings.length > 0
? 'CLI environment ready (with notes)'
: 'CLI environment ready'}
@@ -1175,7 +1435,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
{prepareMessage}
) : null}
- {prepareWarnings.length > 0 ? (
+
+ {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
{prepareWarnings.map((warning) => (
diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx
new file mode 100644
index 00000000..8c4ca9eb
--- /dev/null
+++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx
@@ -0,0 +1,258 @@
+import React from 'react';
+
+import type { TeamProviderId } from '@shared/types';
+import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
+
+export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
+
+export interface ProvisioningProviderCheck {
+ providerId: TeamProviderId;
+ status: ProvisioningProviderCheckStatus;
+ details: string[];
+}
+
+export function getProvisioningProviderLabel(providerId: TeamProviderId): string {
+ switch (providerId) {
+ case 'codex':
+ return 'Codex';
+ case 'gemini':
+ return 'Gemini';
+ case 'anthropic':
+ default:
+ return 'Anthropic';
+ }
+}
+
+export function createInitialProviderChecks(
+ providerIds: TeamProviderId[]
+): ProvisioningProviderCheck[] {
+ return providerIds.map((providerId) => ({
+ providerId,
+ status: 'pending',
+ details: [],
+ }));
+}
+
+export function updateProviderCheck(
+ checks: ProvisioningProviderCheck[],
+ providerId: TeamProviderId,
+ patch: Partial
+): ProvisioningProviderCheck[] {
+ return checks.map((check) =>
+ check.providerId === providerId
+ ? {
+ ...check,
+ ...patch,
+ }
+ : check
+ );
+}
+
+export function failIncompleteProviderChecks(
+ checks: ProvisioningProviderCheck[],
+ detail: string
+): ProvisioningProviderCheck[] {
+ return checks.map((check) =>
+ check.status === 'ready' || check.status === 'notes' || check.status === 'failed'
+ ? check
+ : {
+ ...check,
+ status: 'failed',
+ details: check.details.length > 0 ? check.details : [detail],
+ }
+ );
+}
+
+function getStatusLabel(status: ProvisioningProviderCheckStatus): string {
+ switch (status) {
+ case 'checking':
+ return 'checking...';
+ case 'ready':
+ return 'OK';
+ case 'notes':
+ return 'OK (notes)';
+ case 'failed':
+ return 'ERR';
+ case 'pending':
+ default:
+ return 'queued';
+ }
+}
+
+function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus): string | null {
+ const lower = detail.toLowerCase();
+
+ if (lower.includes('spawn ') && lower.includes(' enoent')) {
+ return 'CLI binary missing';
+ }
+ if (lower.includes('working directory does not exist:')) {
+ return 'Working directory missing';
+ }
+ if (
+ lower.includes('eacces') ||
+ lower.includes('enoexec') ||
+ lower.includes('bad cpu type in executable') ||
+ lower.includes('image not found')
+ ) {
+ return 'CLI binary could not be started';
+ }
+ if (lower.includes('preflight check for `claude -p` did not complete')) {
+ return 'CLI preflight did not complete';
+ }
+ if (lower.includes('not authenticated') || lower.includes('not logged in')) {
+ return 'Authentication required';
+ }
+ if (lower.includes('provider is not configured for runtime use')) {
+ return 'Runtime provider is not configured';
+ }
+ if (lower.includes('claude cli binary failed to start')) {
+ return 'CLI binary could not be started';
+ }
+ if (lower.includes('claude cli preflight check failed')) {
+ return 'CLI preflight failed';
+ }
+
+ if (status === 'notes') {
+ return 'Ready with notes';
+ }
+ if (status === 'failed') {
+ return 'Needs attention';
+ }
+ return null;
+}
+
+function getDisplayStatusText(check: ProvisioningProviderCheck): string {
+ const summary = check.details.find(Boolean)
+ ? summarizeDetail(check.details[0]!, check.status)
+ : null;
+ return summary ?? getStatusLabel(check.status);
+}
+
+export function shouldHideProvisioningProviderStatusList(
+ checks: ProvisioningProviderCheck[],
+ message: string | null | undefined
+): boolean {
+ const normalizedMessage = (message ?? '').trim().toLowerCase();
+ if (!normalizedMessage || checks.length === 0) {
+ return false;
+ }
+
+ return checks.every((check) => {
+ if (check.status !== 'failed') {
+ return false;
+ }
+
+ const summary = getDisplayStatusText(check).toLowerCase();
+ const visibleDetails = check.details.filter(
+ (detail) => detail.trim().toLowerCase() !== normalizedMessage
+ );
+
+ return summary === 'working directory missing' && visibleDetails.length === 0;
+ });
+}
+
+function getStatusColor(status: ProvisioningProviderCheckStatus): string {
+ switch (status) {
+ case 'ready':
+ return 'text-emerald-400';
+ case 'notes':
+ return 'text-sky-300';
+ case 'failed':
+ return 'text-red-300';
+ case 'checking':
+ return 'text-[var(--color-text-secondary)]';
+ case 'pending':
+ default:
+ return 'text-[var(--color-text-muted)]';
+ }
+}
+
+function StatusIcon({ status }: { status: ProvisioningProviderCheckStatus }): React.JSX.Element {
+ if (status === 'checking') {
+ return ;
+ }
+ if (status === 'ready') {
+ return ;
+ }
+ if (status === 'notes' || status === 'failed') {
+ return ;
+ }
+ return ;
+}
+
+export function ProvisioningProviderStatusList({
+ checks,
+ className = '',
+ suppressDetailsMatching,
+}: {
+ checks: ProvisioningProviderCheck[];
+ className?: string;
+ suppressDetailsMatching?: string | null;
+}): React.JSX.Element | null {
+ if (checks.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {checks.map((check) => {
+ const visibleDetails = check.details.filter(
+ (detail) => detail.trim() !== (suppressDetailsMatching ?? '').trim()
+ );
+
+ return (
+
+
+
+
+ {getProvisioningProviderLabel(check.providerId)}: {getDisplayStatusText(check)}
+
+
+ {visibleDetails.length > 0 ? (
+
+ {visibleDetails.map((detail) => (
+
+ {detail}
+
+ ))}
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}
+
+export function getProvisioningFailureHint(
+ message: string | null | undefined,
+ checks: ProvisioningProviderCheck[]
+): string {
+ const combined = [message ?? '', ...checks.flatMap((check) => check.details)]
+ .join('\n')
+ .toLowerCase();
+
+ if (combined.includes('working directory does not exist:')) {
+ return 'Choose an existing working directory, then reopen this dialog.';
+ }
+ if (combined.includes('not authenticated') || combined.includes('not logged in')) {
+ return 'Authenticate the required provider in Claude CLI, then reopen this dialog.';
+ }
+ if (combined.includes('provider is not configured for runtime use')) {
+ return 'Configure the selected provider runtime, then reopen this dialog.';
+ }
+ if (
+ combined.includes('spawn ') ||
+ combined.includes(' enoent') ||
+ combined.includes('eacces') ||
+ combined.includes('enoexec') ||
+ combined.includes('bad cpu type in executable') ||
+ combined.includes('image not found')
+ ) {
+ return 'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.';
+ }
+
+ return 'Resolve the issue above, then reopen this dialog.';
+}
diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx
index b97c4043..c1003bcb 100644
--- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx
+++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Label } from '@renderer/components/ui/label';
import {
@@ -8,6 +8,7 @@ import {
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
+import { useStore } from '@renderer/store';
import { Check, ChevronDown, Info } from 'lucide-react';
// --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) ---
@@ -26,38 +27,9 @@ const OpenAIIcon: React.FC<{ className?: string }> = ({ className }) => (
);
-/** Google Gemini — official sparkle/star mark (Simple Icons) */
-const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => (
+const GoogleGeminiIcon: React.FC<{ className?: string }> = ({ className }) => (
-);
-
-/** Local — server rack icon */
-const LocalIcon: React.FC<{ className?: string }> = ({ className }) => (
-
);
@@ -72,22 +44,88 @@ interface ProviderDef {
const PROVIDERS: ProviderDef[] = [
{ id: 'anthropic', label: 'Anthropic', icon: AnthropicIcon, comingSoon: false },
- { id: 'openai', label: 'OpenAI', icon: OpenAIIcon, comingSoon: true },
- { id: 'google', label: 'Google', icon: GoogleIcon, comingSoon: true },
- { id: 'local', label: 'Local', icon: LocalIcon, comingSoon: true },
+ { id: 'codex', label: 'Codex', icon: OpenAIIcon, comingSoon: false },
+ { id: 'gemini', label: 'Gemini', icon: GoogleGeminiIcon, comingSoon: false },
];
-const ACTIVE_PROVIDER = PROVIDERS[0];
-
-// --- Model options (Anthropic only for now) ---
-
-const MODEL_OPTIONS = [
+const ANTHROPIC_MODEL_OPTIONS = [
{ value: '', label: 'Default' },
{ value: 'opus', label: 'Opus 4.6' },
{ value: 'sonnet', label: 'Sonnet 4.6' },
{ value: 'haiku', label: 'Haiku 4.5' },
] as const;
+const CODEX_MODEL_OPTIONS = [
+ { value: '', label: 'Default' },
+ { value: 'gpt-5.4', label: 'GPT-5.4' },
+ { value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
+ { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
+ { value: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark' },
+ { value: 'gpt-5.2', label: 'GPT-5.2' },
+ { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
+ { value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
+ { value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
+] as const;
+
+const GEMINI_MODEL_OPTIONS = [
+ { value: '', label: 'Default' },
+ { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
+ { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
+ { value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
+] as const;
+
+const MODEL_LABEL_OVERRIDES: Record = {
+ 'claude-sonnet-4-6': 'Sonnet 4.6',
+ 'claude-sonnet-4-6[1m]': 'Sonnet 4.6 (1M)',
+ 'claude-opus-4-6': 'Opus 4.6',
+ 'claude-opus-4-6[1m]': 'Opus 4.6 (1M)',
+ 'claude-haiku-4-5-20251001': 'Haiku 4.5',
+ 'gpt-5.4': 'GPT-5.4',
+ 'gpt-5.4-mini': 'GPT-5.4 Mini',
+ 'gpt-5.3-codex': 'GPT-5.3 Codex',
+ 'gpt-5.3-codex-spark': 'GPT-5.3 Spark',
+ 'gpt-5.2-codex': 'GPT-5.2 Codex',
+ 'gpt-5.2': 'GPT-5.2',
+ 'gpt-5.1-codex-mini': 'GPT-5.1 Mini',
+ 'gpt-5.1-codex-max': 'GPT-5.1 Max',
+ 'gemini-2.5-pro': 'Gemini 2.5 Pro',
+ 'gemini-2.5-flash': 'Gemini 2.5 Flash',
+ 'gemini-2.5-flash-lite': 'Gemini 2.5 Flash Lite',
+};
+
+export function getTeamModelLabel(model: string): string {
+ return MODEL_LABEL_OVERRIDES[model] ?? model;
+}
+
+export function getTeamProviderLabel(providerId: 'anthropic' | 'codex' | 'gemini'): string {
+ switch (providerId) {
+ case 'codex':
+ return 'Codex';
+ case 'gemini':
+ return 'Gemini';
+ case 'anthropic':
+ default:
+ return 'Anthropic';
+ }
+}
+
+export function getTeamEffortLabel(effort: string): string {
+ const trimmed = effort.trim();
+ if (!trimmed) return 'Default';
+ return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
+}
+
+export function formatTeamModelSummary(
+ providerId: 'anthropic' | 'codex' | 'gemini',
+ model: string,
+ effort?: string
+): string {
+ const providerLabel = getTeamProviderLabel(providerId);
+ const modelLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default';
+ const effortLabel = effort?.trim() ? getTeamEffortLabel(effort) : '';
+ return [providerLabel, modelLabel, effortLabel].filter(Boolean).join(' · ');
+}
+
/**
* Computes the effective model string for team provisioning.
* By default adds [1m] suffix for 1M context (Opus/Sonnet).
@@ -96,25 +134,33 @@ const MODEL_OPTIONS = [
*/
export function computeEffectiveTeamModel(
selectedModel: string,
- limitContext: boolean
+ limitContext: boolean,
+ providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic'
): string | undefined {
const base = selectedModel || undefined;
+ if (providerId !== 'anthropic') return base;
if (limitContext) return base;
if (base === 'haiku') return base;
return base ? `${base}[1m]` : 'opus[1m]';
}
export interface TeamModelSelectorProps {
+ providerId: 'anthropic' | 'codex' | 'gemini';
+ onProviderChange: (providerId: 'anthropic' | 'codex' | 'gemini') => void;
value: string;
onValueChange: (value: string) => void;
id?: string;
}
export const TeamModelSelector: React.FC = ({
+ providerId,
+ onProviderChange,
value,
onValueChange,
id,
}) => {
+ const cliStatus = useStore((s) => s.cliStatus);
+ const multimodelAvailable = cliStatus?.flavor === 'free-code';
const [dropdownOpen, setDropdownOpen] = useState(false);
const containerRef = useRef(null);
@@ -132,29 +178,55 @@ export const TeamModelSelector: React.FC = ({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [dropdownOpen]);
- const ProviderIcon = ACTIVE_PROVIDER.icon;
+ const activeProvider = PROVIDERS.find((provider) => provider.id === providerId) ?? PROVIDERS[0];
+ const ProviderIcon = activeProvider.icon;
+ const isProviderSelectable = (candidateProviderId: string): boolean =>
+ multimodelAvailable || candidateProviderId === 'anthropic';
+ const activeProviderSelectable = isProviderSelectable(providerId);
+ const runtimeModels =
+ cliStatus?.providers.find((provider) => provider.providerId === providerId)?.models ?? [];
+ const modelOptions = useMemo(() => {
+ const fallback =
+ providerId === 'codex'
+ ? CODEX_MODEL_OPTIONS
+ : providerId === 'gemini'
+ ? GEMINI_MODEL_OPTIONS
+ : ANTHROPIC_MODEL_OPTIONS;
+ if (runtimeModels.length === 0) {
+ return [...fallback];
+ }
+ const dynamicOptions = runtimeModels.map((model) => ({
+ value: model,
+ label: getTeamModelLabel(model),
+ }));
+ return [{ value: '', label: 'Default' }, ...dynamicOptions];
+ }, [providerId, runtimeModels]);
return (
-
-
- {/* Provider button */}
+
+
- {/* Model pills */}
- {MODEL_OPTIONS.map((opt) => (
+ {/* Provider dropdown */}
+ {dropdownOpen && (
+
+ {PROVIDERS.map((provider, index) => {
+ const Icon = provider.icon;
+ const isActive = provider.id === activeProvider.id;
+ const isFirst = index === 0;
+ const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon;
+
+ return (
+
+ {prevWasActive && !isFirst && (
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ {!multimodelAvailable && (
+
+ Codex and Gemini require Multimodel mode.
+
+ )}
+
+
+ {modelOptions.map((opt) => (
))}
-
- {/* Provider dropdown */}
- {dropdownOpen && (
-
- {PROVIDERS.map((provider, index) => {
- const Icon = provider.icon;
- const isActive = provider.id === ACTIVE_PROVIDER.id;
- const isFirst = index === 0;
- const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon;
-
- return (
-
- {prevWasActive && !isFirst && (
-
- )}
-
-
- );
- })}
-
- )}
);
diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx
new file mode 100644
index 00000000..3321f7cc
--- /dev/null
+++ b/src/renderer/components/team/members/LeadModelRow.tsx
@@ -0,0 +1,138 @@
+import React, { useState } from 'react';
+
+import { Checkbox } from '@renderer/components/ui/checkbox';
+import { Label } from '@renderer/components/ui/label';
+import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
+import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
+import {
+ getTeamModelLabel,
+ TeamModelSelector,
+} from '@renderer/components/team/dialogs/TeamModelSelector';
+import { getTeamColorSet } from '@renderer/constants/teamColors';
+import { useTheme } from '@renderer/hooks/useTheme';
+import { getMemberColorByName } from '@shared/constants/memberColors';
+import { ChevronDown, ChevronRight, Info } from 'lucide-react';
+
+import { Button } from '../../ui/button';
+
+import type { EffortLevel, TeamProviderId } from '@shared/types';
+
+interface LeadModelRowProps {
+ providerId: TeamProviderId;
+ model: string;
+ effort?: EffortLevel;
+ limitContext: boolean;
+ onProviderChange: (providerId: TeamProviderId) => void;
+ onModelChange: (model: string) => void;
+ onEffortChange: (effort: string) => void;
+ onLimitContextChange: (value: boolean) => void;
+ syncModelsWithTeammates: boolean;
+ onSyncModelsWithTeammatesChange: (value: boolean) => void;
+}
+
+export const LeadModelRow = ({
+ providerId,
+ model,
+ effort,
+ limitContext,
+ onProviderChange,
+ onModelChange,
+ onEffortChange,
+ onLimitContextChange,
+ syncModelsWithTeammates,
+ onSyncModelsWithTeammatesChange,
+}: LeadModelRowProps): React.JSX.Element => {
+ const { isLight } = useTheme();
+ const [modelExpanded, setModelExpanded] = useState(false);
+ const leadColorSet = getTeamColorSet(getMemberColorByName('lead'));
+ const modelButtonLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default';
+
+ return (
+
+
+
+
+ lead
+
+
+
+
+ Team Lead
+
+
+
+
+
+
+
+
+ onSyncModelsWithTeammatesChange(checked === true)}
+ />
+
+
+
+
+ {modelExpanded ? (
+
+
+
+
+
+
+
+ These settings control the team lead and act as the default runtime for teammates that
+ do not have their own override.
+
+
+
+ ) : null}
+
+ );
+};
diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx
index c47d2ef8..85c53fed 100644
--- a/src/renderer/components/team/members/MemberCard.tsx
+++ b/src/renderer/components/team/members/MemberCard.tsx
@@ -26,6 +26,7 @@ import type {
interface MemberCardProps {
member: ResolvedTeamMember;
memberColor: string;
+ runtimeSummary?: string;
taskCounts?: TaskStatusCounts | null;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
@@ -46,6 +47,7 @@ interface MemberCardProps {
export const MemberCard = ({
member,
memberColor,
+ runtimeSummary,
taskCounts,
isTeamAlive,
isTeamProvisioning,
@@ -131,42 +133,49 @@ export const MemberCard = ({
aria-label={presenceLabel}
/>
-
-
- {displayMemberName(member.name)}
-
- {member.gitBranch ? (
-
-
- {member.gitBranch}
+
+
+
+ {displayMemberName(member.name)}
- ) : null}
- {currentTask ? (
-
- ) : null}
- {reviewTask ? (
-
- ) : null}
- {!activityTask && isAwaitingReply ? (
- <>
-
-
- awaiting reply
+ {member.gitBranch ? (
+
+
+ {member.gitBranch}
- >
+ ) : null}
+ {currentTask ? (
+
+ ) : null}
+ {reviewTask ? (
+
+ ) : null}
+ {!activityTask && isAwaitingReply ? (
+ <>
+
+
+ awaiting reply
+
+ >
+ ) : null}
+
+ {runtimeSummary ? (
+
+ {runtimeSummary}
+
) : null}
{(() => {
diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx
index f27d0055..1cff48df 100644
--- a/src/renderer/components/team/members/MemberDraftRow.tsx
+++ b/src/renderer/components/team/members/MemberDraftRow.tsx
@@ -1,6 +1,10 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
-import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector';
+import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
+import {
+ getTeamModelLabel,
+ TeamModelSelector,
+} from '@renderer/components/team/dialogs/TeamModelSelector';
import { RoleSelect } from '@renderer/components/team/RoleSelect';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
@@ -16,6 +20,7 @@ import { ChevronDown, ChevronRight, Info, Trash2 } from 'lucide-react';
import type { MemberDraft } from './membersEditorTypes';
import type { InlineChip } from '@renderer/types/inlineChip';
import type { MentionSuggestion } from '@renderer/types/mention';
+import type { EffortLevel, TeamProviderId } from '@shared/types';
interface MemberDraftRowProps {
member: MemberDraft;
@@ -29,11 +34,20 @@ interface MemberDraftRowProps {
showWorkflow?: boolean;
onWorkflowChange?: (id: string, workflow: string) => void;
onWorkflowChipsChange?: (id: string, chips: InlineChip[]) => void;
+ onProviderChange: (id: string, providerId: TeamProviderId) => void;
+ onModelChange: (id: string, model: string) => void;
+ onEffortChange: (id: string, effort: string) => void;
+ inheritedProviderId?: TeamProviderId;
+ inheritedModel?: string;
+ inheritedEffort?: EffortLevel;
draftKeyPrefix?: string;
projectPath?: string | null;
mentionSuggestions?: MentionSuggestion[];
taskSuggestions?: MentionSuggestion[];
teamSuggestions?: MentionSuggestion[];
+ lockProviderModel?: boolean;
+ forceInheritedModelSettings?: boolean;
+ modelLockReason?: string;
}
export const MemberDraftRow = ({
@@ -48,11 +62,20 @@ export const MemberDraftRow = ({
showWorkflow = false,
onWorkflowChange,
onWorkflowChipsChange,
+ onProviderChange,
+ onModelChange,
+ onEffortChange,
+ inheritedProviderId = 'anthropic',
+ inheritedModel = '',
+ inheritedEffort,
draftKeyPrefix,
projectPath,
mentionSuggestions = [],
taskSuggestions,
teamSuggestions,
+ lockProviderModel = false,
+ forceInheritedModelSettings = false,
+ modelLockReason,
}: MemberDraftRowProps): React.JSX.Element => {
const { isLight } = useTheme();
const memberColorSet = getTeamColorSet(
@@ -122,10 +145,22 @@ export const MemberDraftRow = ({
const suggestionsExcludingSelf = mentionSuggestions.filter(
(s) => s.name.toLowerCase() !== member.name.trim().toLowerCase()
);
+ const effectiveProviderId = forceInheritedModelSettings
+ ? inheritedProviderId
+ : (member.providerId ?? inheritedProviderId);
+ const effectiveModel = forceInheritedModelSettings
+ ? inheritedModel
+ : (member.model ?? inheritedModel);
+ const effectiveEffort = forceInheritedModelSettings
+ ? inheritedEffort
+ : (member.effort ?? inheritedEffort);
+ const modelButtonLabel = effectiveModel?.trim()
+ ? getTeamModelLabel(effectiveModel.trim())
+ : 'Default';
return (
-
- {showWorkflow && onWorkflowChange ? (
+
+
+ {showWorkflow && onWorkflowChange ? (
+
+ ) : null}
+
+
+
- ) : null}
-
-
+
{showWorkflow && onWorkflowChange && workflowExpanded ? (
@@ -241,14 +282,38 @@ export const MemberDraftRow = ({
) : null}
{modelExpanded && (
-
- {}} />
-
+ {
+ if (lockProviderModel) return;
+ onProviderChange(member.id, providerId);
+ }}
+ value={effectiveModel ?? ''}
+ onValueChange={(value) => {
+ if (lockProviderModel) return;
+ onModelChange(member.id, value);
+ }}
+ id={`member-${member.id}-model`}
+ />
+ {
+ if (lockProviderModel) return;
+ onEffortChange(member.id, value);
+ }}
+ id={`member-${member.id}-effort`}
+ />
+ {lockProviderModel && (
+
+ {modelLockReason ??
+ 'Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.'}
+
+ )}
- Claude Code doesn't support per-member model selection yet — all teammates
- inherit the team launch model. We plan to solve this via a local proxy.
+ If this teammate uses a different provider than the lead, they will be started in a
+ separate process automatically.
diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx
index efdfc083..07906fde 100644
--- a/src/renderer/components/team/members/MemberList.tsx
+++ b/src/renderer/components/team/members/MemberList.tsx
@@ -1,5 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ getTeamEffortLabel,
+ getTeamModelLabel,
+ getTeamProviderLabel,
+} from '@renderer/components/team/dialogs/TeamModelSelector';
+import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
@@ -27,6 +33,7 @@ interface MemberListProps {
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
+ launchParams?: TeamLaunchParams;
onMemberClick?: (member: ResolvedTeamMember) => void;
onSendMessage?: (member: ResolvedTeamMember) => void;
onAssignTask?: (member: ResolvedTeamMember) => void;
@@ -42,6 +49,7 @@ export const MemberList = ({
isTeamAlive,
isTeamProvisioning,
leadActivity,
+ launchParams,
onMemberClick,
onSendMessage,
onAssignTask,
@@ -77,6 +85,45 @@ export const MemberList = ({
const removedMembers = members.filter((m) => m.removedAt);
const colorMap = buildMemberColorMap(members);
+ const buildRuntimeSummary = useCallback(
+ (member: ResolvedTeamMember): string | undefined => {
+ const hasMemberOverride = Boolean(member.providerId || member.model || member.effort);
+ if (!hasMemberOverride && launchParams) {
+ return undefined;
+ }
+
+ const defaultProvider = launchParams?.providerId ?? 'anthropic';
+ const memberProvider = member.providerId ?? defaultProvider;
+ const defaultModel = launchParams?.model?.trim() || '';
+ const memberModel = member.model?.trim() || '';
+ const defaultEffort = launchParams?.effort;
+ const memberEffort = member.effort;
+
+ const showProvider =
+ !launchParams || Boolean(member.providerId && memberProvider !== defaultProvider);
+ const showModel = !launchParams
+ ? Boolean(memberModel)
+ : Boolean(memberModel && memberModel !== defaultModel);
+ const showEffort = !launchParams
+ ? Boolean(memberEffort)
+ : Boolean(memberEffort && memberEffort !== defaultEffort);
+
+ const parts: string[] = [];
+ if (showProvider) {
+ parts.push(getTeamProviderLabel(memberProvider));
+ }
+ if (showModel) {
+ parts.push(getTeamModelLabel(memberModel));
+ }
+ if (showEffort && memberEffort) {
+ parts.push(getTeamEffortLabel(memberEffort));
+ }
+
+ return parts.length > 0 ? parts.join(' · ') : undefined;
+ },
+ [launchParams]
+ );
+
if (members.length === 0) {
return (
@@ -111,6 +158,7 @@ export const MemberList = ({
reviewTask={isRemoved ? null : reviewTask}
isAwaitingReply={isRemoved ? false : awaitingReply}
isRemoved={isRemoved}
+ runtimeSummary={isRemoved ? buildRuntimeSummary(member) : buildRuntimeSummary(member)}
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
spawnError={isRemoved ? undefined : spawnEntry?.error}
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined}
diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx
index 608fbb54..d9fab145 100644
--- a/src/renderer/components/team/members/MembersEditorSection.tsx
+++ b/src/renderer/components/team/members/MembersEditorSection.tsx
@@ -20,6 +20,7 @@ import {
import type { MemberDraft } from './membersEditorTypes';
import type { InlineChip } from '@renderer/types/inlineChip';
import type { MentionSuggestion } from '@renderer/types/mention';
+import type { EffortLevel, TeamProviderId } from '@shared/types';
function membersToJsonText(drafts: MemberDraft[]): string {
const arr = drafts
@@ -30,6 +31,9 @@ function membersToJsonText(drafts: MemberDraft[]): string {
if (role) obj.role = role;
const workflow = getWorkflowForExport(d);
if (workflow) obj.workflow = workflow;
+ if (d.providerId && d.providerId !== 'anthropic') obj.providerId = d.providerId;
+ if (d.model?.trim()) obj.model = d.model.trim();
+ if (d.effort) obj.effort = d.effort;
return obj;
});
return JSON.stringify(arr, null, 2);
@@ -42,6 +46,13 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
const name = typeof item.name === 'string' ? item.name : '';
const role = typeof item.role === 'string' ? item.role.trim() : '';
const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : '';
+ const providerId: TeamProviderId =
+ item.providerId === 'codex' || item.providerId === 'gemini' ? item.providerId : 'anthropic';
+ const model = typeof item.model === 'string' ? item.model.trim() : '';
+ const effort: EffortLevel | undefined =
+ item.effort === 'low' || item.effort === 'medium' || item.effort === 'high'
+ ? item.effort
+ : undefined;
const presetRoles: readonly string[] = PRESET_ROLES;
const isPreset = presetRoles.includes(role);
return createMemberDraft({
@@ -49,6 +60,9 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
customRole: role && !isPreset ? role : '',
workflow: workflow || undefined,
+ providerId,
+ model,
+ effort,
});
});
}
@@ -74,6 +88,16 @@ export interface MembersEditorSectionProps {
hideContent?: boolean;
/** Existing team members — used to reserve their colors so drafts get the next available ones */
existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[];
+ /** Default provider to use for newly added member rows. */
+ defaultProviderId?: TeamProviderId;
+ /** When true, provider/model controls stay read-only for existing rows. */
+ lockProviderModel?: boolean;
+ inheritedProviderId?: TeamProviderId;
+ inheritedModel?: string;
+ inheritedEffort?: EffortLevel;
+ inheritModelSettingsByDefault?: boolean;
+ forceInheritedModelSettings?: boolean;
+ modelLockReason?: string;
}
export const MembersEditorSection = ({
@@ -90,6 +114,14 @@ export const MembersEditorSection = ({
headerExtra,
hideContent = false,
existingMembers,
+ defaultProviderId = 'anthropic',
+ lockProviderModel = false,
+ inheritedProviderId,
+ inheritedModel,
+ inheritedEffort,
+ inheritModelSettingsByDefault = false,
+ forceInheritedModelSettings = false,
+ modelLockReason,
}: MembersEditorSectionProps): React.JSX.Element => {
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState('');
@@ -150,13 +182,52 @@ export const MembersEditorSection = ({
onChange(members.map((c) => (c.id === memberId ? { ...c, workflowChips } : c)));
};
+ const updateMemberProvider = (memberId: string, providerId: TeamProviderId): void => {
+ onChange(
+ members.map((c) =>
+ c.id === memberId
+ ? {
+ ...c,
+ providerId,
+ model: c.providerId === providerId ? c.model : '',
+ }
+ : c
+ )
+ );
+ };
+
+ const updateMemberModel = (memberId: string, model: string): void => {
+ onChange(members.map((c) => (c.id === memberId ? { ...c, model } : c)));
+ };
+
+ const updateMemberEffort = (memberId: string, effort: string): void => {
+ onChange(
+ members.map((c) =>
+ c.id === memberId
+ ? {
+ ...c,
+ effort:
+ effort === 'low' || effort === 'medium' || effort === 'high' ? effort : undefined,
+ }
+ : c
+ )
+ );
+ };
+
const removeMember = (memberId: string): void => {
onChange(members.filter((c) => c.id !== memberId));
};
const addMember = (): void => {
const suggestedName = getNextSuggestedMemberName(members.map((member) => member.name));
- onChange([...members, createMemberDraft({ name: suggestedName })]);
+ onChange([
+ ...members,
+ createMemberDraft(
+ inheritModelSettingsByDefault
+ ? { name: suggestedName }
+ : { name: suggestedName, providerId: defaultProviderId }
+ ),
+ ]);
};
const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
@@ -207,11 +278,20 @@ export const MembersEditorSection = ({
showWorkflow={showWorkflow}
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
+ onProviderChange={updateMemberProvider}
+ onModelChange={updateMemberModel}
+ onEffortChange={updateMemberEffort}
+ inheritedProviderId={inheritedProviderId}
+ inheritedModel={inheritedModel}
+ inheritedEffort={inheritedEffort}
+ forceInheritedModelSettings={forceInheritedModelSettings}
draftKeyPrefix={draftKeyPrefix}
projectPath={projectPath}
mentionSuggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions}
+ lockProviderModel={lockProviderModel}
+ modelLockReason={modelLockReason}
/>
))}
{jsonEditorOpen && showJsonEditor ? (
@@ -243,7 +323,9 @@ export {
buildMemberDraftColorMap,
buildMemberDraftSuggestions,
buildMembersFromDrafts,
+ clearMemberModelOverrides,
createMemberDraft,
+ createMemberDraftsFromInputs,
getMemberDraftRole,
validateMemberNameInline,
} from './membersEditorUtils';
diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx
new file mode 100644
index 00000000..8f467b5d
--- /dev/null
+++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx
@@ -0,0 +1,121 @@
+import React from 'react';
+
+import { MembersEditorSection } from './MembersEditorSection';
+import { LeadModelRow } from './LeadModelRow';
+
+import type { MemberDraft } from './membersEditorTypes';
+import type { MentionSuggestion } from '@renderer/types/mention';
+import type { EffortLevel, TeamProviderId } from '@shared/types';
+
+interface TeamRosterEditorSectionProps {
+ members: MemberDraft[];
+ onMembersChange: (members: MemberDraft[]) => void;
+ fieldError?: string;
+ validateMemberName?: (name: string) => string | null;
+ showWorkflow?: boolean;
+ showJsonEditor?: boolean;
+ draftKeyPrefix?: string;
+ projectPath?: string | null;
+ taskSuggestions?: MentionSuggestion[];
+ teamSuggestions?: MentionSuggestion[];
+ hideMembersContent?: boolean;
+ existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[];
+ defaultProviderId?: TeamProviderId;
+ inheritedProviderId: TeamProviderId;
+ inheritedModel: string;
+ inheritedEffort?: EffortLevel;
+ inheritModelSettingsByDefault?: boolean;
+ forceInheritedModelSettings?: boolean;
+ lockProviderModel?: boolean;
+ modelLockReason?: string;
+ providerId: TeamProviderId;
+ model: string;
+ effort?: EffortLevel;
+ limitContext: boolean;
+ onProviderChange: (providerId: TeamProviderId) => void;
+ onModelChange: (model: string) => void;
+ onEffortChange: (effort: string) => void;
+ onLimitContextChange: (value: boolean) => void;
+ syncModelsWithTeammates: boolean;
+ onSyncModelsWithTeammatesChange: (value: boolean) => void;
+ headerTop?: React.ReactNode;
+ headerBottom?: React.ReactNode;
+}
+
+export const TeamRosterEditorSection = ({
+ members,
+ onMembersChange,
+ fieldError,
+ validateMemberName,
+ showWorkflow = false,
+ showJsonEditor = true,
+ draftKeyPrefix,
+ projectPath,
+ taskSuggestions,
+ teamSuggestions,
+ hideMembersContent = false,
+ existingMembers,
+ defaultProviderId = 'anthropic',
+ inheritedProviderId,
+ inheritedModel,
+ inheritedEffort,
+ inheritModelSettingsByDefault = false,
+ forceInheritedModelSettings = false,
+ lockProviderModel = false,
+ modelLockReason,
+ providerId,
+ model,
+ effort,
+ limitContext,
+ onProviderChange,
+ onModelChange,
+ onEffortChange,
+ onLimitContextChange,
+ syncModelsWithTeammates,
+ onSyncModelsWithTeammatesChange,
+ headerTop,
+ headerBottom,
+}: TeamRosterEditorSectionProps): React.JSX.Element => {
+ return (
+
+ {headerTop}
+
+ {headerBottom}
+
+ }
+ />
+ );
+};
diff --git a/src/renderer/components/team/members/membersEditorTypes.ts b/src/renderer/components/team/members/membersEditorTypes.ts
index f4649308..d73ac590 100644
--- a/src/renderer/components/team/members/membersEditorTypes.ts
+++ b/src/renderer/components/team/members/membersEditorTypes.ts
@@ -1,4 +1,5 @@
import type { InlineChip } from '@renderer/types/inlineChip';
+import type { EffortLevel, TeamProviderId } from '@shared/types';
export interface MemberDraft {
id: string;
@@ -7,6 +8,9 @@ export interface MemberDraft {
customRole: string;
workflow?: string;
workflowChips?: InlineChip[];
+ providerId?: TeamProviderId;
+ model?: string;
+ effort?: EffortLevel;
}
export interface MembersEditorValue {
diff --git a/src/renderer/components/team/members/membersEditorUtils.ts b/src/renderer/components/team/members/membersEditorUtils.ts
index 8bafddc2..906f0a2c 100644
--- a/src/renderer/components/team/members/membersEditorUtils.ts
+++ b/src/renderer/components/team/members/membersEditorUtils.ts
@@ -1,10 +1,10 @@
-import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles';
+import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import type { MemberDraft } from './membersEditorTypes';
import type { MentionSuggestion } from '@renderer/types/mention';
-import type { TeamProvisioningMemberInput } from '@shared/types';
+import type { EffortLevel, TeamProvisioningMemberInput, TeamProviderId } from '@shared/types';
function isValidMemberName(name: string): boolean {
if (name.length < 1 || name.length > 128) return false;
@@ -33,9 +33,60 @@ export function createMemberDraft(initial?: Partial): MemberDraft {
roleSelection: initial?.roleSelection ?? '',
customRole: initial?.customRole ?? '',
workflow: initial?.workflow,
+ providerId: initial?.providerId,
+ model: initial?.model ?? '',
+ effort: initial?.effort,
};
}
+export function createMemberDraftsFromInputs(
+ members: readonly {
+ name: string;
+ role?: string;
+ workflow?: string;
+ providerId?: TeamProviderId;
+ model?: string;
+ effort?: EffortLevel;
+ removedAt?: number | string | null;
+ }[]
+): MemberDraft[] {
+ return members
+ .filter((member) => !member.removedAt)
+ .map((member) => {
+ const role = typeof member.role === 'string' ? member.role.trim() : '';
+ const presetRoles: readonly string[] = PRESET_ROLES;
+ const isPreset = presetRoles.includes(role);
+ return createMemberDraft({
+ name: member.name,
+ roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
+ customRole: role && !isPreset ? role : '',
+ workflow: member.workflow,
+ providerId:
+ member.providerId === 'codex' || member.providerId === 'gemini'
+ ? member.providerId
+ : 'anthropic',
+ model: member.model ?? '',
+ effort: normalizeDraftEffort(member.effort),
+ });
+ });
+}
+
+export function clearMemberModelOverrides(member: MemberDraft): MemberDraft {
+ return {
+ ...member,
+ providerId: undefined,
+ model: '',
+ effort: undefined,
+ };
+}
+
+function normalizeDraftEffort(value: string | undefined): EffortLevel | undefined {
+ if (value === 'low' || value === 'medium' || value === 'high') {
+ return value;
+ }
+ return undefined;
+}
+
interface ExistingMemberColorInput {
name: string;
color?: string;
@@ -113,6 +164,21 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
const result: TeamProvisioningMemberInput = { name, role };
const workflow = getWorkflowForExport(member);
if (workflow) result.workflow = workflow;
+ const providerId: TeamProviderId =
+ member.providerId === 'codex' || member.providerId === 'gemini'
+ ? member.providerId
+ : 'anthropic';
+ if (providerId !== 'anthropic') {
+ result.providerId = providerId;
+ }
+ const model = member.model?.trim();
+ if (model) {
+ result.model = model;
+ }
+ const effort = normalizeDraftEffort(member.effort);
+ if (effort) {
+ result.effort = effort;
+ }
return result;
})
.filter((member): member is NonNullable => member !== null);
diff --git a/src/renderer/hooks/useCreateTeamDraft.ts b/src/renderer/hooks/useCreateTeamDraft.ts
index e537b3b4..5eaadf2b 100644
--- a/src/renderer/hooks/useCreateTeamDraft.ts
+++ b/src/renderer/hooks/useCreateTeamDraft.ts
@@ -33,6 +33,8 @@ export interface UseCreateTeamDraftResult {
setTeamName: (v: string) => void;
members: MemberDraft[];
setMembers: (v: MemberDraft[]) => void;
+ syncModelsWithLead: boolean;
+ setSyncModelsWithLead: (v: boolean) => void;
cwdMode: 'project' | 'custom';
setCwdMode: (v: 'project' | 'custom') => void;
selectedProjectPath: string;
@@ -63,13 +65,18 @@ const DEBOUNCE_MS = 400;
// ---------------------------------------------------------------------------
function serializeMembers(members: MemberDraft[]): SerializedMemberDraft[] {
- return members.map(({ id, name, roleSelection, customRole, workflow }) => ({
- id,
- name,
- roleSelection,
- customRole,
- workflow,
- }));
+ return members.map(
+ ({ id, name, roleSelection, customRole, workflow, providerId, model, effort }) => ({
+ id,
+ name,
+ roleSelection,
+ customRole,
+ workflow,
+ providerId,
+ model,
+ effort,
+ })
+ );
}
function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[] {
@@ -80,6 +87,9 @@ function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[]
roleSelection: m.roleSelection,
customRole: m.customRole,
workflow: m.workflow,
+ providerId: m.providerId,
+ model: m.model,
+ effort: m.effort,
})
);
}
@@ -92,6 +102,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
// ── State ──────────────────────────────────────────────────────────────
const [teamName, setTeamNameState] = useState('');
const [members, setMembersState] = useState([]);
+ const [syncModelsWithLead, setSyncModelsWithLeadState] = useState(true);
const [cwdMode, setCwdModeState] = useState<'project' | 'custom'>('project');
const [selectedProjectPath, setSelectedProjectPathState] = useState('');
const [customCwd, setCustomCwdState] = useState('');
@@ -103,6 +114,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
// ── Refs (latest values for debounced callbacks) ───────────────────────
const teamNameRef = useRef('');
const membersRef = useRef([]);
+ const syncModelsWithLeadRef = useRef(true);
const cwdModeRef = useRef<'project' | 'custom'>('project');
const selectedProjectPathRef = useRef('');
const customCwdRef = useRef('');
@@ -128,6 +140,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
version: 1,
teamName: teamNameRef.current,
members: serializeMembers(membersRef.current),
+ syncModelsWithLead: syncModelsWithLeadRef.current,
cwdMode: cwdModeRef.current,
selectedProjectPath: selectedProjectPathRef.current,
customCwd: customCwdRef.current,
@@ -187,6 +200,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
teamNameRef.current = snap.teamName;
membersRef.current = deserialized;
+ syncModelsWithLeadRef.current = snap.syncModelsWithLead ?? true;
cwdModeRef.current = snap.cwdMode;
selectedProjectPathRef.current = snap.selectedProjectPath;
customCwdRef.current = snap.customCwd;
@@ -196,6 +210,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
setTeamNameState(snap.teamName);
setMembersState(deserialized);
+ setSyncModelsWithLeadState(snap.syncModelsWithLead ?? true);
setCwdModeState(snap.cwdMode);
setSelectedProjectPathState(snap.selectedProjectPath);
setCustomCwdState(snap.customCwd);
@@ -260,6 +275,16 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
[scheduleSave]
);
+ const setSyncModelsWithLead = useCallback(
+ (v: boolean) => {
+ userTouchedRef.current = true;
+ syncModelsWithLeadRef.current = v;
+ setSyncModelsWithLeadState(v);
+ scheduleSave();
+ },
+ [scheduleSave]
+ );
+
const setCwdMode = useCallback(
(v: 'project' | 'custom') => {
userTouchedRef.current = true;
@@ -334,6 +359,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
teamNameRef.current = '';
membersRef.current = [];
+ syncModelsWithLeadRef.current = true;
cwdModeRef.current = 'project';
selectedProjectPathRef.current = '';
customCwdRef.current = '';
@@ -343,6 +369,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
setTeamNameState('');
setMembersState([]);
+ setSyncModelsWithLeadState(true);
setCwdModeState('project');
setSelectedProjectPathState('');
setCustomCwdState('');
@@ -358,6 +385,8 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
setTeamName,
members,
setMembers,
+ syncModelsWithLead,
+ setSyncModelsWithLead,
cwdMode,
setCwdMode,
selectedProjectPath,
diff --git a/src/renderer/services/createTeamDraftStorage.ts b/src/renderer/services/createTeamDraftStorage.ts
index 98eb52cc..eb616f60 100644
--- a/src/renderer/services/createTeamDraftStorage.ts
+++ b/src/renderer/services/createTeamDraftStorage.ts
@@ -25,12 +25,16 @@ export interface SerializedMemberDraft {
roleSelection: string;
customRole: string;
workflow?: string;
+ providerId?: 'anthropic' | 'codex' | 'gemini';
+ model?: string;
+ effort?: 'low' | 'medium' | 'high';
}
export interface CreateTeamDraftSnapshot {
version: number;
teamName: string;
members: SerializedMemberDraft[];
+ syncModelsWithLead?: boolean;
cwdMode: 'project' | 'custom';
selectedProjectPath: string;
customCwd: string;
@@ -57,7 +61,16 @@ function isValidMember(m: unknown): m is SerializedMemberDraft {
typeof obj.id === 'string' &&
typeof obj.name === 'string' &&
typeof obj.roleSelection === 'string' &&
- typeof obj.customRole === 'string'
+ typeof obj.customRole === 'string' &&
+ (obj.providerId === undefined ||
+ obj.providerId === 'anthropic' ||
+ obj.providerId === 'codex' ||
+ obj.providerId === 'gemini') &&
+ (obj.model === undefined || typeof obj.model === 'string') &&
+ (obj.effort === undefined ||
+ obj.effort === 'low' ||
+ obj.effort === 'medium' ||
+ obj.effort === 'high')
);
}
@@ -70,6 +83,7 @@ function isValidSnapshot(data: unknown): data is CreateTeamDraftSnapshot {
typeof obj.teamName === 'string' &&
Array.isArray(obj.members) &&
obj.members.every(isValidMember) &&
+ (obj.syncModelsWithLead === undefined || typeof obj.syncModelsWithLead === 'boolean') &&
(obj.cwdMode === 'project' || obj.cwdMode === 'custom') &&
typeof obj.selectedProjectPath === 'string' &&
typeof obj.customCwd === 'string' &&
@@ -154,6 +168,7 @@ function emptySnapshot(): CreateTeamDraftSnapshot {
version: SNAPSHOT_VERSION,
teamName: '',
members: [],
+ syncModelsWithLead: true,
cwdMode: 'project',
selectedProjectPath: '',
customCwd: '',
diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts
index c4e1952d..71818524 100644
--- a/src/renderer/store/slices/teamSlice.ts
+++ b/src/renderer/store/slices/teamSlice.ts
@@ -559,6 +559,7 @@ export interface GlobalTaskDetailState {
/** Per-team launch parameters shown in the header badge. */
export interface TeamLaunchParams {
+ providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string; // 'opus' | 'sonnet' | 'haiku'
effort?: EffortLevel;
limitContext?: boolean;
@@ -1949,6 +1950,7 @@ export const createTeamSlice: StateCreator = (set,
// Persist per-team launch params (model, effort, limit context)
const baseModel = extractBaseModel(request.model);
const params: TeamLaunchParams = {
+ providerId: request.providerId ?? 'anthropic',
model: baseModel || 'default',
effort: request.effort,
limitContext: request.limitContext ?? false,
@@ -2125,6 +2127,7 @@ export const createTeamSlice: StateCreator = (set,
// Persist per-team launch params (model, effort, limit context)
const baseModel = extractBaseModel(request.model);
const params: TeamLaunchParams = {
+ providerId: request.providerId ?? 'anthropic',
model: baseModel || 'default',
effort: request.effort,
limitContext: request.limitContext ?? false,
diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts
index e52ab6ea..e7994209 100644
--- a/src/shared/types/api.ts
+++ b/src/shared/types/api.ts
@@ -427,7 +427,11 @@ export interface TeamsAPI {
permanentlyDeleteTeam: (teamName: string) => Promise;
getSavedRequest: (teamName: string) => Promise;
deleteDraft: (teamName: string) => Promise;
- prepareProvisioning: (cwd?: string) => Promise;
+ prepareProvisioning: (
+ cwd?: string,
+ providerId?: TeamLaunchRequest['providerId'],
+ providerIds?: TeamLaunchRequest['providerId'][]
+ ) => Promise;
createTeam: (request: TeamCreateRequest) => Promise;
getProvisioningStatus: (runId: string) => Promise;
cancelProvisioning: (runId: string) => Promise;
diff --git a/src/shared/types/schedule.ts b/src/shared/types/schedule.ts
index 606b81eb..9f76b1b1 100644
--- a/src/shared/types/schedule.ts
+++ b/src/shared/types/schedule.ts
@@ -5,7 +5,7 @@
* Repository Pattern abstraction allows swapping storage backend (JSON → sql.js/Drizzle).
*/
-import type { EffortLevel } from './team';
+import type { EffortLevel, TeamProviderId } from './team';
// =============================================================================
// Schedule Status Types
@@ -49,6 +49,7 @@ export interface Schedule {
export interface ScheduleLaunchConfig {
cwd: string;
prompt: string;
+ providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
skipPermissions?: boolean;
diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts
index b8f4e817..c833eec0 100644
--- a/src/shared/types/team.ts
+++ b/src/shared/types/team.ts
@@ -5,6 +5,9 @@ export interface TeamMember {
role?: string;
/** Per-agent workflow/instructions injected into spawn prompt. */
workflow?: string;
+ providerId?: TeamProviderId;
+ model?: string;
+ effort?: EffortLevel;
color?: string;
joinedAt?: number;
cwd?: string;
@@ -471,6 +474,9 @@ export interface ResolvedTeamMember {
agentType?: string;
role?: string;
workflow?: string;
+ providerId?: TeamProviderId;
+ model?: string;
+ effort?: EffortLevel;
cwd?: string;
/** Set only when member's git branch differs from the lead's branch. */
gitBranch?: string;
@@ -503,11 +509,13 @@ export interface TeamData {
}
export type EffortLevel = 'low' | 'medium' | 'high';
+export type TeamProviderId = 'anthropic' | 'codex' | 'gemini';
export interface TeamLaunchRequest {
teamName: string;
cwd: string;
prompt?: string;
+ providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
/** When true, context window is limited to 200K tokens instead of the default. */
@@ -633,6 +641,9 @@ export interface TeamProvisioningMemberInput {
role?: string;
/** Per-agent workflow/instructions injected into spawn prompt. */
workflow?: string;
+ providerId?: TeamProviderId;
+ model?: string;
+ effort?: EffortLevel;
}
export interface TeamCreateRequest {
@@ -643,6 +654,7 @@ export interface TeamCreateRequest {
members: TeamProvisioningMemberInput[];
cwd: string;
prompt?: string;
+ providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
/** When true, context window is limited to 200K tokens instead of the default. */
@@ -780,6 +792,9 @@ export interface AddMemberRequest {
name: string;
role?: string;
workflow?: string;
+ providerId?: TeamProviderId;
+ model?: string;
+ effort?: EffortLevel;
}
export interface RemoveMemberRequest {
diff --git a/src/shared/utils/cliArgsParser.ts b/src/shared/utils/cliArgsParser.ts
index 5adae22c..5a22ac91 100644
--- a/src/shared/utils/cliArgsParser.ts
+++ b/src/shared/utils/cliArgsParser.ts
@@ -24,6 +24,12 @@ export const PROTECTED_CLI_FLAGS = new Set([
'--mcp-config',
'--disallowedTools',
'--verbose',
+ '--model',
+ '--effort',
+ '--resume',
+ '--permission-mode',
+ '--permission-prompt-tool',
+ '--dangerously-skip-permissions',
]);
/**
diff --git a/test/main/services/team/TeamMemberResolver.test.ts b/test/main/services/team/TeamMemberResolver.test.ts
index 24ec3686..e0718abf 100644
--- a/test/main/services/team/TeamMemberResolver.test.ts
+++ b/test/main/services/team/TeamMemberResolver.test.ts
@@ -91,6 +91,28 @@ describe('TeamMemberResolver', () => {
expect(names).not.toContain('dream-team.team-lead');
});
+ it('ignores leaked generated agent ids from inbox file names', () => {
+ const resolver = new TeamMemberResolver();
+ const config: TeamConfig = {
+ name: 'Team',
+ members: [{ name: 'team-lead', agentType: 'team-lead', role: 'lead' }],
+ };
+ const metaMembers: TeamConfig['members'] = [
+ { name: 'alice', agentType: 'general-purpose' },
+ { name: 'bob', agentType: 'general-purpose' },
+ ];
+ const inboxNames = ['a3975f80d37fbcea1', 'alice', 'a68a8f6a643e59bfd'];
+
+ const members = resolver.resolveMembers(config, metaMembers, inboxNames, [], []);
+ const names = members.map((m) => m.name);
+
+ expect(names).toContain('alice');
+ expect(names).toContain('bob');
+ expect(names).toContain('team-lead');
+ expect(names).not.toContain('a3975f80d37fbcea1');
+ expect(names).not.toContain('a68a8f6a643e59bfd');
+ });
+
it('keeps dotted names when they are explicitly configured members', () => {
const resolver = new TeamMemberResolver();
const config: TeamConfig = {
diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts
index 41285f18..e5d7874e 100644
--- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts
+++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts
@@ -79,6 +79,38 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
expect(probeSpy.mock.calls[1]?.[1]).toBe(cwdB);
});
+ it('checks each unique provider during multi-provider prepare and blocks on provider auth failure', async () => {
+ const svc = new TeamProvisioningService();
+ const getCachedOrProbeResult = vi.spyOn(svc as any, 'getCachedOrProbeResult');
+ getCachedOrProbeResult.mockImplementation(async (_cwd: unknown, providerId: unknown) => {
+ if (providerId === 'codex') {
+ return {
+ claudePath: '/fake/claude',
+ authSource: 'none',
+ warning: 'Not logged in to Codex runtime',
+ };
+ }
+ return {
+ claudePath: '/fake/claude',
+ authSource: 'oauth_token',
+ };
+ });
+
+ const result = await svc.prepareForProvisioning(tempRoot, {
+ forceFresh: true,
+ providerId: 'anthropic',
+ providerIds: ['codex', 'anthropic'],
+ });
+
+ expect(result.ready).toBe(false);
+ expect(result.message).toBe('Codex: Not logged in to Codex runtime');
+ expect(getCachedOrProbeResult).toHaveBeenCalledTimes(2);
+ expect(getCachedOrProbeResult.mock.calls.map((call) => call[1])).toEqual([
+ 'anthropic',
+ 'codex',
+ ]);
+ });
+
it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => {
const svc = new TeamProvisioningService();
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
diff --git a/test/main/services/team/TeamProvisioningServiceRoster.test.ts b/test/main/services/team/TeamProvisioningServiceRoster.test.ts
index 53e69e78..aa86048d 100644
--- a/test/main/services/team/TeamProvisioningServiceRoster.test.ts
+++ b/test/main/services/team/TeamProvisioningServiceRoster.test.ts
@@ -59,6 +59,27 @@ describe('TeamProvisioningService (launch roster discovery)', () => {
expect(result.members.map((m: { name: string }) => m.name)).toEqual(['alice-2']);
});
+ it('inbox fallback merges provider/model overrides from config for multimodel reconnect', async () => {
+ const svc = new TeamProvisioningService(
+ {} as never,
+ { listInboxNames: vi.fn(async () => ['bob']) } as never,
+ { getMembers: vi.fn(async () => []) } as never,
+ {} as never
+ );
+
+ const configRaw = JSON.stringify({
+ name: 't',
+ members: [{ name: 'bob', role: 'reviewer', provider: 'codex', model: 'gpt-5.4' }],
+ });
+
+ const result = await (svc as unknown as any).resolveLaunchExpectedMembers('t', configRaw);
+ expect(result.source).toBe('inboxes');
+ expect(result.members).toEqual([
+ { name: 'bob', role: 'reviewer', workflow: undefined, providerId: 'codex', model: 'gpt-5.4' },
+ ]);
+ expect(result.warning).toContain('best-effort');
+ });
+
it('members.meta.json fallback never returns reserved names (user/team-lead)', async () => {
const svc = new TeamProvisioningService(
{} as never,
From 759cae2669ab61ade38e1c1b88c39681199022ee Mon Sep 17 00:00:00 2001
From: iliya
Date: Thu, 2 Apr 2026 10:23:57 +0300
Subject: [PATCH 03/67] fix(pricing): include codex and gemini model costs
---
resources/pricing.json | 621 +++++++++++++++++++++++++-----
scripts/fetch-pricing-data.ts | 37 +-
src/shared/utils/pricing.ts | 9 +
test/shared/utils/pricing.test.ts | 21 +
4 files changed, 576 insertions(+), 112 deletions(-)
diff --git a/resources/pricing.json b/resources/pricing.json
index 17b13eec..be42ab2e 100644
--- a/resources/pricing.json
+++ b/resources/pricing.json
@@ -36,7 +36,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"anthropic.claude-haiku-4-5@20251001": {
"cache_creation_input_token_cost": 0.00000125,
@@ -59,7 +60,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
- "supports_native_streaming": true
+ "supports_native_streaming": true,
+ "supports_native_structured_output": true
},
"anthropic.claude-3-5-sonnet-20240620-v1:0": {
"input_cost_per_token": 0.000003,
@@ -281,22 +283,19 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 159
+ "tool_use_system_prompt_tokens": 159,
+ "supports_native_structured_output": true
},
"anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.00000625,
- "cache_creation_input_token_cost_above_200k_tokens": 0.0000125,
"cache_read_input_token_cost": 5e-7,
- "cache_read_input_token_cost_above_200k_tokens": 0.000001,
"input_cost_per_token": 0.000005,
- "input_cost_per_token_above_200k_tokens": 0.00001,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.000025,
- "output_cost_per_token_above_200k_tokens": 0.0000375,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
@@ -311,22 +310,19 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"global.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.00000625,
- "cache_creation_input_token_cost_above_200k_tokens": 0.0000125,
"cache_read_input_token_cost": 5e-7,
- "cache_read_input_token_cost_above_200k_tokens": 0.000001,
"input_cost_per_token": 0.000005,
- "input_cost_per_token_above_200k_tokens": 0.00001,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.000025,
- "output_cost_per_token_above_200k_tokens": 0.0000375,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
@@ -341,22 +337,19 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"us.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.000006875,
- "cache_creation_input_token_cost_above_200k_tokens": 0.00001375,
"cache_read_input_token_cost": 5.5e-7,
- "cache_read_input_token_cost_above_200k_tokens": 0.0000011,
"input_cost_per_token": 0.0000055,
- "input_cost_per_token_above_200k_tokens": 0.000011,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.0000275,
- "output_cost_per_token_above_200k_tokens": 0.00004125,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
@@ -371,22 +364,19 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"eu.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.000006875,
- "cache_creation_input_token_cost_above_200k_tokens": 0.00001375,
"cache_read_input_token_cost": 5.5e-7,
- "cache_read_input_token_cost_above_200k_tokens": 0.0000011,
"input_cost_per_token": 0.0000055,
- "input_cost_per_token_above_200k_tokens": 0.000011,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.0000275,
- "output_cost_per_token_above_200k_tokens": 0.00004125,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
@@ -401,22 +391,19 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"au.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.000006875,
- "cache_creation_input_token_cost_above_200k_tokens": 0.00001375,
"cache_read_input_token_cost": 5.5e-7,
- "cache_read_input_token_cost_above_200k_tokens": 0.0000011,
"input_cost_per_token": 0.0000055,
- "input_cost_per_token_above_200k_tokens": 0.000011,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.0000275,
- "output_cost_per_token_above_200k_tokens": 0.00004125,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
@@ -431,22 +418,19 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.00000375,
- "cache_creation_input_token_cost_above_200k_tokens": 0.0000075,
"cache_read_input_token_cost": 3e-7,
- "cache_read_input_token_cost_above_200k_tokens": 6e-7,
"input_cost_per_token": 0.000003,
- "input_cost_per_token_above_200k_tokens": 0.000006,
"litellm_provider": "bedrock_converse",
- "max_input_tokens": 200000,
+ "max_input_tokens": 1000000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 0.000015,
- "output_cost_per_token_above_200k_tokens": 0.0000225,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
@@ -461,22 +445,19 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"global.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.00000375,
- "cache_creation_input_token_cost_above_200k_tokens": 0.0000075,
"cache_read_input_token_cost": 3e-7,
- "cache_read_input_token_cost_above_200k_tokens": 6e-7,
"input_cost_per_token": 0.000003,
- "input_cost_per_token_above_200k_tokens": 0.000006,
"litellm_provider": "bedrock_converse",
- "max_input_tokens": 200000,
+ "max_input_tokens": 1000000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 0.000015,
- "output_cost_per_token_above_200k_tokens": 0.0000225,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
@@ -491,22 +472,19 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"us.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.000004125,
- "cache_creation_input_token_cost_above_200k_tokens": 0.00000825,
"cache_read_input_token_cost": 3.3e-7,
- "cache_read_input_token_cost_above_200k_tokens": 6.6e-7,
"input_cost_per_token": 0.0000033,
- "input_cost_per_token_above_200k_tokens": 0.0000066,
"litellm_provider": "bedrock_converse",
- "max_input_tokens": 200000,
+ "max_input_tokens": 1000000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 0.0000165,
- "output_cost_per_token_above_200k_tokens": 0.00002475,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
@@ -521,22 +499,19 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"eu.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.000004125,
- "cache_creation_input_token_cost_above_200k_tokens": 0.00000825,
"cache_read_input_token_cost": 3.3e-7,
- "cache_read_input_token_cost_above_200k_tokens": 6.6e-7,
"input_cost_per_token": 0.0000033,
- "input_cost_per_token_above_200k_tokens": 0.0000066,
"litellm_provider": "bedrock_converse",
- "max_input_tokens": 200000,
+ "max_input_tokens": 1000000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 0.0000165,
- "output_cost_per_token_above_200k_tokens": 0.00002475,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
@@ -551,22 +526,19 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"au.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.000004125,
- "cache_creation_input_token_cost_above_200k_tokens": 0.00000825,
"cache_read_input_token_cost": 3.3e-7,
- "cache_read_input_token_cost_above_200k_tokens": 6.6e-7,
"input_cost_per_token": 0.0000033,
- "input_cost_per_token_above_200k_tokens": 0.0000066,
"litellm_provider": "bedrock_converse",
- "max_input_tokens": 200000,
+ "max_input_tokens": 1000000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 0.0000165,
- "output_cost_per_token_above_200k_tokens": 0.00002475,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
@@ -581,7 +553,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"anthropic.claude-sonnet-4-20250514-v1:0": {
"cache_creation_input_token_cost": 0.00000375,
@@ -641,7 +614,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 159
+ "tool_use_system_prompt_tokens": 159,
+ "supports_native_structured_output": true
},
"anthropic.claude-v1": {
"input_cost_per_token": 0.000008,
@@ -733,7 +707,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"apac.anthropic.claude-3-sonnet-20240229-v1:0": {
"input_cost_per_token": 0.000003,
@@ -809,7 +784,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"azure_ai/claude-haiku-4-5": {
"cache_creation_input_token_cost": 0.00000125,
@@ -928,7 +904,7 @@
"cache_read_input_token_cost": 3e-7,
"input_cost_per_token": 0.000003,
"litellm_provider": "azure_ai",
- "max_input_tokens": 200000,
+ "max_input_tokens": 1000000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
@@ -1452,18 +1428,14 @@
},
"claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.00000375,
- "cache_creation_input_token_cost_above_200k_tokens": 0.0000075,
"cache_read_input_token_cost": 3e-7,
- "cache_read_input_token_cost_above_200k_tokens": 6e-7,
"input_cost_per_token": 0.000003,
- "input_cost_per_token_above_200k_tokens": 0.000006,
"litellm_provider": "anthropic",
- "max_input_tokens": 200000,
+ "max_input_tokens": 1000000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 0.000015,
- "output_cost_per_token_above_200k_tokens": 0.0000225,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
@@ -1644,19 +1616,15 @@
},
"claude-opus-4-6": {
"cache_creation_input_token_cost": 0.00000625,
- "cache_creation_input_token_cost_above_200k_tokens": 0.0000125,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
- "cache_read_input_token_cost_above_200k_tokens": 0.000001,
"input_cost_per_token": 0.000005,
- "input_cost_per_token_above_200k_tokens": 0.00001,
"litellm_provider": "anthropic",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.000025,
- "output_cost_per_token_above_200k_tokens": 0.0000375,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
@@ -1679,19 +1647,15 @@
},
"claude-opus-4-6-20260205": {
"cache_creation_input_token_cost": 0.00000625,
- "cache_creation_input_token_cost_above_200k_tokens": 0.0000125,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
- "cache_read_input_token_cost_above_200k_tokens": 0.000001,
"input_cost_per_token": 0.000005,
- "input_cost_per_token_above_200k_tokens": 0.00001,
"litellm_provider": "anthropic",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.000025,
- "output_cost_per_token_above_200k_tokens": 0.0000375,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
@@ -1968,7 +1932,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"eu.anthropic.claude-3-5-sonnet-20240620-v1:0": {
"input_cost_per_token": 0.000003,
@@ -2182,7 +2147,143 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
+ },
+ "gemini-2.5-flash": {
+ "cache_read_input_token_cost": 3e-8,
+ "input_cost_per_audio_token": 0.000001,
+ "input_cost_per_token": 3e-7,
+ "litellm_provider": "vertex_ai-language-models",
+ "max_audio_length_hours": 8.4,
+ "max_audio_per_prompt": 1,
+ "max_images_per_prompt": 3000,
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 65535,
+ "max_pdf_size_mb": 30,
+ "max_tokens": 65535,
+ "max_video_length": 1,
+ "max_videos_per_prompt": 10,
+ "mode": "chat",
+ "output_cost_per_reasoning_token": 0.0000025,
+ "output_cost_per_token": 0.0000025,
+ "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview",
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/completions",
+ "/v1/batch"
+ ],
+ "supported_modalities": [
+ "text",
+ "image",
+ "audio",
+ "video"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_audio_output": false,
+ "supports_function_calling": true,
+ "supports_parallel_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_url_context": true,
+ "supports_vision": true,
+ "supports_web_search": true
+ },
+ "gemini-2.5-flash-lite": {
+ "cache_read_input_token_cost": 1e-8,
+ "input_cost_per_audio_token": 3e-7,
+ "input_cost_per_token": 1e-7,
+ "litellm_provider": "vertex_ai-language-models",
+ "max_audio_length_hours": 8.4,
+ "max_audio_per_prompt": 1,
+ "max_images_per_prompt": 3000,
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 65535,
+ "max_pdf_size_mb": 30,
+ "max_tokens": 65535,
+ "max_video_length": 1,
+ "max_videos_per_prompt": 10,
+ "mode": "chat",
+ "output_cost_per_reasoning_token": 4e-7,
+ "output_cost_per_token": 4e-7,
+ "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview",
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/completions",
+ "/v1/batch"
+ ],
+ "supported_modalities": [
+ "text",
+ "image",
+ "audio",
+ "video"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_audio_output": false,
+ "supports_function_calling": true,
+ "supports_parallel_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_url_context": true,
+ "supports_vision": true,
+ "supports_web_search": true
+ },
+ "gemini-2.5-pro": {
+ "cache_read_input_token_cost": 1.25e-7,
+ "cache_read_input_token_cost_above_200k_tokens": 2.5e-7,
+ "cache_creation_input_token_cost_above_200k_tokens": 2.5e-7,
+ "input_cost_per_token": 0.00000125,
+ "input_cost_per_token_above_200k_tokens": 0.0000025,
+ "litellm_provider": "vertex_ai-language-models",
+ "max_audio_length_hours": 8.4,
+ "max_audio_per_prompt": 1,
+ "max_images_per_prompt": 3000,
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 65535,
+ "max_pdf_size_mb": 30,
+ "max_tokens": 65535,
+ "max_video_length": 1,
+ "max_videos_per_prompt": 10,
+ "mode": "chat",
+ "output_cost_per_token": 0.00001,
+ "output_cost_per_token_above_200k_tokens": 0.000015,
+ "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing",
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/completions"
+ ],
+ "supported_modalities": [
+ "text",
+ "image",
+ "audio",
+ "video"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_audio_input": true,
+ "supports_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_video_input": true,
+ "supports_vision": true,
+ "supports_web_search": true
},
"gmi/anthropic/claude-opus-4.5": {
"input_cost_per_token": 0.000005,
@@ -2256,7 +2357,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"global.anthropic.claude-sonnet-4-20250514-v1:0": {
"cache_creation_input_token_cost": 0.00000375,
@@ -2308,7 +2410,287 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
+ },
+ "gpt-5.2": {
+ "cache_read_input_token_cost": 1.75e-7,
+ "cache_read_input_token_cost_priority": 3.5e-7,
+ "input_cost_per_token": 0.00000175,
+ "input_cost_per_token_priority": 0.0000035,
+ "litellm_provider": "openai",
+ "max_input_tokens": 272000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 0.000014,
+ "output_cost_per_token_priority": 0.000028,
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/batch",
+ "/v1/responses"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "text",
+ "image"
+ ],
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_parallel_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_service_tier": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_none_reasoning_effort": true,
+ "supports_xhigh_reasoning_effort": true,
+ "supports_minimal_reasoning_effort": true
+ },
+ "gpt-5.4": {
+ "cache_read_input_token_cost": 2.5e-7,
+ "cache_read_input_token_cost_above_272k_tokens": 5e-7,
+ "cache_read_input_token_cost_flex": 1.3e-7,
+ "cache_read_input_token_cost_priority": 5e-7,
+ "cache_read_input_token_cost_above_272k_tokens_priority": 0.000001,
+ "input_cost_per_token": 0.0000025,
+ "input_cost_per_token_above_272k_tokens": 0.000005,
+ "input_cost_per_token_flex": 0.00000125,
+ "input_cost_per_token_batches": 0.00000125,
+ "input_cost_per_token_priority": 0.000005,
+ "input_cost_per_token_above_272k_tokens_priority": 0.00001,
+ "litellm_provider": "openai",
+ "max_input_tokens": 1050000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 0.000015,
+ "output_cost_per_token_above_272k_tokens": 0.0000225,
+ "output_cost_per_token_flex": 0.0000075,
+ "output_cost_per_token_batches": 0.0000075,
+ "output_cost_per_token_priority": 0.0000225,
+ "output_cost_per_token_above_272k_tokens_priority": 0.00003375,
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/batch",
+ "/v1/responses"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_parallel_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_service_tier": true,
+ "supports_vision": true,
+ "supports_none_reasoning_effort": true,
+ "supports_xhigh_reasoning_effort": true,
+ "supports_minimal_reasoning_effort": true
+ },
+ "gpt-5.4-mini": {
+ "cache_read_input_token_cost": 7.5e-8,
+ "cache_read_input_token_cost_flex": 1e-8,
+ "cache_read_input_token_cost_batches": 3.8e-8,
+ "input_cost_per_token": 7.5e-7,
+ "input_cost_per_token_flex": 3.75e-7,
+ "input_cost_per_token_batches": 3.75e-7,
+ "litellm_provider": "openai",
+ "max_input_tokens": 272000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 0.0000045,
+ "output_cost_per_token_flex": 0.00000225,
+ "output_cost_per_token_batches": 0.00000225,
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/batch",
+ "/v1/responses"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_parallel_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_service_tier": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_none_reasoning_effort": true,
+ "supports_xhigh_reasoning_effort": true,
+ "supports_minimal_reasoning_effort": false
+ },
+ "gpt-5.1-codex-max": {
+ "cache_read_input_token_cost": 1.25e-7,
+ "input_cost_per_token": 0.00000125,
+ "litellm_provider": "openai",
+ "max_input_tokens": 272000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "responses",
+ "output_cost_per_token": 0.00001,
+ "supported_endpoints": [
+ "/v1/responses"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_parallel_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_system_messages": false,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_none_reasoning_effort": false,
+ "supports_xhigh_reasoning_effort": true,
+ "supports_minimal_reasoning_effort": true
+ },
+ "gpt-5.1-codex-mini": {
+ "cache_read_input_token_cost": 2.5e-8,
+ "cache_read_input_token_cost_priority": 4.5e-8,
+ "input_cost_per_token": 2.5e-7,
+ "input_cost_per_token_priority": 4.5e-7,
+ "litellm_provider": "openai",
+ "max_input_tokens": 272000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "responses",
+ "output_cost_per_token": 0.000002,
+ "output_cost_per_token_priority": 0.0000036,
+ "supported_endpoints": [
+ "/v1/responses"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_parallel_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_system_messages": false,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_none_reasoning_effort": false,
+ "supports_xhigh_reasoning_effort": false,
+ "supports_minimal_reasoning_effort": true
+ },
+ "gpt-5.2-codex": {
+ "cache_read_input_token_cost": 1.75e-7,
+ "cache_read_input_token_cost_priority": 3.5e-7,
+ "input_cost_per_token": 0.00000175,
+ "input_cost_per_token_priority": 0.0000035,
+ "litellm_provider": "openai",
+ "max_input_tokens": 272000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "responses",
+ "output_cost_per_token": 0.000014,
+ "output_cost_per_token_priority": 0.000028,
+ "supported_endpoints": [
+ "/v1/responses"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_parallel_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_system_messages": false,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_none_reasoning_effort": false,
+ "supports_xhigh_reasoning_effort": true,
+ "supports_minimal_reasoning_effort": true
+ },
+ "gpt-5.3-codex": {
+ "cache_read_input_token_cost": 1.75e-7,
+ "cache_read_input_token_cost_priority": 3.5e-7,
+ "input_cost_per_token": 0.00000175,
+ "input_cost_per_token_priority": 0.0000035,
+ "litellm_provider": "openai",
+ "max_input_tokens": 272000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "responses",
+ "output_cost_per_token": 0.000014,
+ "output_cost_per_token_priority": 0.000028,
+ "supported_endpoints": [
+ "/v1/responses"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_parallel_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_system_messages": false,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_none_reasoning_effort": false,
+ "supports_xhigh_reasoning_effort": false,
+ "supports_minimal_reasoning_effort": true
},
"gradient_ai/anthropic-claude-3-opus": {
"input_cost_per_token": 0.000015,
@@ -2394,7 +2776,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"jp.anthropic.claude-haiku-4-5-20251001-v1:0": {
"cache_creation_input_token_cost": 0.000001375,
@@ -2416,7 +2799,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"openrouter/anthropic/claude-3-haiku": {
"input_cost_per_image": 0.0004,
@@ -2746,7 +3130,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"us.anthropic.claude-3-5-sonnet-20240620-v1:0": {
"input_cost_per_token": 0.000003,
@@ -2904,7 +3289,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"au.anthropic.claude-haiku-4-5-20251001-v1:0": {
"cache_creation_input_token_cost": 0.000001375,
@@ -2925,7 +3311,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 346
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true
},
"us.anthropic.claude-opus-4-20250514-v1:0": {
"cache_creation_input_token_cost": 0.00001875,
@@ -2977,7 +3364,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 159
+ "tool_use_system_prompt_tokens": 159,
+ "supports_native_structured_output": true
},
"global.anthropic.claude-opus-4-5-20251101-v1:0": {
"cache_creation_input_token_cost": 0.00000625,
@@ -3003,7 +3391,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 159
+ "tool_use_system_prompt_tokens": 159,
+ "supports_native_structured_output": true
},
"eu.anthropic.claude-opus-4-5-20251101-v1:0": {
"cache_creation_input_token_cost": 0.00000625,
@@ -3029,7 +3418,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
- "tool_use_system_prompt_tokens": 159
+ "tool_use_system_prompt_tokens": 159,
+ "supports_native_structured_output": true
},
"us.anthropic.claude-sonnet-4-20250514-v1:0": {
"cache_creation_input_token_cost": 0.00000375,
@@ -3642,18 +4032,14 @@
},
"vertex_ai/claude-opus-4-6": {
"cache_creation_input_token_cost": 0.00000625,
- "cache_creation_input_token_cost_above_200k_tokens": 0.0000125,
"cache_read_input_token_cost": 5e-7,
- "cache_read_input_token_cost_above_200k_tokens": 0.000001,
"input_cost_per_token": 0.000005,
- "input_cost_per_token_above_200k_tokens": 0.00001,
"litellm_provider": "vertex_ai-anthropic_models",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.000025,
- "output_cost_per_token_above_200k_tokens": 0.0000375,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
@@ -3672,18 +4058,14 @@
},
"vertex_ai/claude-opus-4-6@default": {
"cache_creation_input_token_cost": 0.00000625,
- "cache_creation_input_token_cost_above_200k_tokens": 0.0000125,
"cache_read_input_token_cost": 5e-7,
- "cache_read_input_token_cost_above_200k_tokens": 0.000001,
"input_cost_per_token": 0.000005,
- "input_cost_per_token_above_200k_tokens": 0.00001,
"litellm_provider": "vertex_ai-anthropic_models",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.000025,
- "output_cost_per_token_above_200k_tokens": 0.0000375,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
@@ -3728,18 +4110,14 @@
},
"vertex_ai/claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.00000375,
- "cache_creation_input_token_cost_above_200k_tokens": 0.0000075,
"cache_read_input_token_cost": 3e-7,
- "cache_read_input_token_cost_above_200k_tokens": 6e-7,
"input_cost_per_token": 0.000003,
- "input_cost_per_token_above_200k_tokens": 0.000006,
"litellm_provider": "vertex_ai-anthropic_models",
- "max_input_tokens": 200000,
+ "max_input_tokens": 1000000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 0.000015,
- "output_cost_per_token_above_200k_tokens": 0.0000225,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
@@ -3871,18 +4249,14 @@
},
"vertex_ai/claude-sonnet-4-6@default": {
"cache_creation_input_token_cost": 0.00000375,
- "cache_creation_input_token_cost_above_200k_tokens": 0.0000075,
"cache_read_input_token_cost": 3e-7,
- "cache_read_input_token_cost_above_200k_tokens": 6e-7,
"input_cost_per_token": 0.000003,
- "input_cost_per_token_above_200k_tokens": 0.000006,
"litellm_provider": "vertex_ai-anthropic_models",
- "max_input_tokens": 200000,
+ "max_input_tokens": 1000000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 0.000015,
- "output_cost_per_token_above_200k_tokens": 0.0000225,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
@@ -3898,5 +4272,42 @@
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
}
+ },
+ "gpt-5.3-codex-spark": {
+ "cache_read_input_token_cost": 1.75e-7,
+ "cache_read_input_token_cost_priority": 3.5e-7,
+ "input_cost_per_token": 0.00000175,
+ "input_cost_per_token_priority": 0.0000035,
+ "litellm_provider": "openai",
+ "max_input_tokens": 272000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "responses",
+ "output_cost_per_token": 0.000014,
+ "output_cost_per_token_priority": 0.000028,
+ "supported_endpoints": [
+ "/v1/responses"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_parallel_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_system_messages": false,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_none_reasoning_effort": false,
+ "supports_xhigh_reasoning_effort": false,
+ "supports_minimal_reasoning_effort": true
}
}
\ No newline at end of file
diff --git a/scripts/fetch-pricing-data.ts b/scripts/fetch-pricing-data.ts
index 703a0e21..7994d3a8 100644
--- a/scripts/fetch-pricing-data.ts
+++ b/scripts/fetch-pricing-data.ts
@@ -2,7 +2,7 @@
/**
* Fetch latest model pricing from LiteLLM and save to renderer assets.
- * Filters to Claude models only to reduce bundle size.
+ * Filters to the models this app currently exposes in the UI/runtime to reduce bundle size.
* Runs automatically during prebuild.
*/
@@ -41,11 +41,28 @@ function isValidModelPricing(entry: unknown): entry is ModelPricing {
);
}
+const EXPLICIT_MODEL_ALLOWLIST = new Set([
+ 'gpt-5.4',
+ 'gpt-5.4-mini',
+ 'gpt-5.3-codex',
+ 'gpt-5.2-codex',
+ 'gpt-5.2',
+ 'gpt-5.1-codex-max',
+ 'gpt-5.1-codex-mini',
+ 'gemini-2.5-pro',
+ 'gemini-2.5-flash',
+ 'gemini-2.5-flash-lite',
+]);
+
function isClaudeModel(modelName: string): boolean {
const lower = modelName.toLowerCase();
return lower.includes('claude');
}
+function isIncludedModel(modelName: string): boolean {
+ return isClaudeModel(modelName) || EXPLICIT_MODEL_ALLOWLIST.has(modelName);
+}
+
async function fetchPricingData(): Promise> {
console.log('Fetching pricing data from LiteLLM...');
@@ -63,16 +80,22 @@ async function fetchPricingData(): Promise> {
const data = (await response.json()) as Record;
console.log(`Fetched pricing for ${Object.keys(data).length} models`);
- // Filter to Claude models only and validate entries
- const claudeModels: Record = {};
+ // Filter to the models currently exposed by this app and validate entries.
+ const selectedModels: Record = {};
for (const [modelName, entry] of Object.entries(data)) {
- if (isClaudeModel(modelName) && isValidModelPricing(entry)) {
- claudeModels[modelName] = entry;
+ if (isIncludedModel(modelName) && isValidModelPricing(entry)) {
+ selectedModels[modelName] = entry;
}
}
- console.log(`Filtered to ${Object.keys(claudeModels).length} Claude models`);
- return claudeModels;
+ // LiteLLM currently publishes no priced top-level entry for gpt-5.3-codex-spark.
+ // Keep cost estimation non-zero by aliasing it to the closest published Codex tier.
+ if (!selectedModels['gpt-5.3-codex-spark'] && selectedModels['gpt-5.3-codex']) {
+ selectedModels['gpt-5.3-codex-spark'] = selectedModels['gpt-5.3-codex'];
+ }
+
+ console.log(`Filtered to ${Object.keys(selectedModels).length} supported models`);
+ return selectedModels;
} catch (error) {
clearTimeout(timeout);
if (error instanceof Error && error.name === 'AbortError') {
diff --git a/src/shared/utils/pricing.ts b/src/shared/utils/pricing.ts
index bf504472..816a34d8 100644
--- a/src/shared/utils/pricing.ts
+++ b/src/shared/utils/pricing.ts
@@ -22,6 +22,9 @@ export interface DisplayPricing {
const TIER_THRESHOLD = 200_000;
const PRICING_MAP = pricingData as Record;
+const PRICING_ALIASES: Record = {
+ 'gpt-5.3-codex-spark': 'gpt-5.3-codex',
+};
// Pre-compute lowercase key map for O(1) case-insensitive lookups
const LOWERCASE_KEY_MAP = new Map();
@@ -55,6 +58,12 @@ export function getPricing(modelName: string): LiteLLMPricing | null {
return tryGetPricing(originalKey);
}
+ const alias = PRICING_ALIASES[lowerName];
+ if (alias) {
+ const aliased = tryGetPricing(alias);
+ if (aliased) return aliased;
+ }
+
return null;
}
diff --git a/test/shared/utils/pricing.test.ts b/test/shared/utils/pricing.test.ts
index b711e17c..f81b2909 100644
--- a/test/shared/utils/pricing.test.ts
+++ b/test/shared/utils/pricing.test.ts
@@ -20,6 +20,27 @@ describe('Shared Pricing Module', () => {
expect(pricing).not.toBeNull();
});
+ it('should find pricing for codex runtime models', () => {
+ const pricing = getPricing('gpt-5.1-codex-mini');
+ expect(pricing).not.toBeNull();
+ expect(pricing!.input_cost_per_token).toBeGreaterThan(0);
+ expect(pricing!.output_cost_per_token).toBeGreaterThan(0);
+ });
+
+ it('should alias codex spark pricing to the closest published codex tier', () => {
+ const sparkPricing = getPricing('gpt-5.3-codex-spark');
+ const codexPricing = getPricing('gpt-5.3-codex');
+ expect(sparkPricing).not.toBeNull();
+ expect(sparkPricing).toEqual(codexPricing);
+ });
+
+ it('should find pricing for gemini runtime models', () => {
+ const pricing = getPricing('gemini-2.5-flash-lite');
+ expect(pricing).not.toBeNull();
+ expect(pricing!.input_cost_per_token).toBeGreaterThan(0);
+ expect(pricing!.output_cost_per_token).toBeGreaterThan(0);
+ });
+
it('should return null for unknown models', () => {
const pricing = getPricing('totally-fake-model-xyz');
expect(pricing).toBeNull();
From 074b614469d5ca71e56fd781b93218773b74a1bd Mon Sep 17 00:00:00 2001
From: iliya
Date: Thu, 2 Apr 2026 15:15:32 +0300
Subject: [PATCH 04/67] feat: improve team launch model and status UX
---
.../infrastructure/CliInstallerService.ts | 9 +
.../services/team/TeamMcpConfigBuilder.ts | 34 ++-
src/main/services/team/TeamMemberResolver.ts | 28 +-
.../services/team/TeamProvisioningService.ts | 265 ++++++++++++++++--
src/renderer/api/httpClient.ts | 1 +
.../components/dashboard/CliStatusBanner.tsx | 51 +++-
.../settings/sections/CliStatusSection.tsx | 8 +-
.../components/team/TeamDetailView.tsx | 53 ++--
src/renderer/components/team/TeamListView.tsx | 4 +-
.../team/dialogs/CreateTeamDialog.tsx | 60 +++-
.../team/dialogs/LaunchTeamDialog.tsx | 202 ++++++++-----
.../team/dialogs/TeamModelSelector.tsx | 22 +-
.../components/team/members/LeadModelRow.tsx | 70 ++---
.../team/members/MemberDraftRow.tsx | 92 ++++--
.../team/members/MembersEditorSection.tsx | 67 ++++-
.../team/members/TeamRosterEditorSection.tsx | 3 +
.../team/members/membersEditorTypes.ts | 1 +
.../team/members/membersEditorUtils.ts | 32 +++
src/shared/types/cliInstaller.ts | 2 +
test/renderer/store/cliInstallerSlice.test.ts | 2 +
20 files changed, 786 insertions(+), 220 deletions(-)
diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts
index 70c9cfe2..0310ee44 100644
--- a/src/main/services/infrastructure/CliInstallerService.ts
+++ b/src/main/services/infrastructure/CliInstallerService.ts
@@ -433,6 +433,7 @@ export class CliInstallerService {
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
+ authStatusChecking: true,
authMethod: null,
providers,
};
@@ -497,6 +498,7 @@ export class CliInstallerService {
if (binaryPath) {
r.installed = true;
r.binaryPath = binaryPath;
+ r.authStatusChecking = true;
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
try {
@@ -522,6 +524,7 @@ export class CliInstallerService {
]);
} else {
// No binary — still check latest version for "install" prompt
+ r.authStatusChecking = false;
if (r.supportsSelfUpdate) {
await this.fetchLatestVersion(r);
}
@@ -541,6 +544,7 @@ export class CliInstallerService {
diag: CliInstallerStatusRunDiag
): Promise {
if (result.flavor === 'free-code') {
+ result.authStatusChecking = true;
try {
const providers = await this.multimodelBridgeService.getProviderStatuses(
binaryPath,
@@ -556,10 +560,12 @@ export class CliInstallerService {
result.authLoggedIn = providers.some((provider) => provider.authenticated);
result.authMethod =
providers.find((provider) => provider.authenticated)?.authMethod ?? null;
+ result.authStatusChecking = false;
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) });
} catch (error) {
const msg = getErrorMessage(error);
diag.authLastError = msg;
+ result.authStatusChecking = false;
logger.warn(`Provider status check failed for claude-multimodel: ${msg}`);
}
return;
@@ -578,6 +584,7 @@ export class CliInstallerService {
const auth = parseClaudeAuthStatusStdout(authStdout);
result.authLoggedIn = auth.loggedIn === true;
result.authMethod = auth.authMethod ?? null;
+ result.authStatusChecking = false;
diag.authLastError = null;
logger.info(
`Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}` +
@@ -598,6 +605,7 @@ export class CliInstallerService {
`Auth status check failed after ${AUTH_STATUS_MAX_RETRIES} attempts: ${msg}`
);
result.authLoggedIn = false;
+ result.authStatusChecking = false;
}
}
}
@@ -621,6 +629,7 @@ export class CliInstallerService {
if (timer) {
clearTimeout(timer);
}
+ result.authStatusChecking = false;
diag.authTimedOut = hitAuthTimeout;
}
}
diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts
index 2b96a824..0a15034d 100644
--- a/src/main/services/team/TeamMcpConfigBuilder.ts
+++ b/src/main/services/team/TeamMcpConfigBuilder.ts
@@ -69,6 +69,13 @@ function getSourceServerEntry(): string {
return path.join(getWorkspaceMcpServerDir(), 'src', 'index.ts');
}
+function getWorkspaceTsxBinCandidates(): string[] {
+ return [
+ path.join(getWorkspaceMcpServerDir(), 'node_modules', '.bin', 'tsx'),
+ path.join(getWorkspaceRoot(), 'node_modules', '.bin', 'tsx'),
+ ];
+}
+
async function pathExists(targetPath: string): Promise {
try {
await fs.promises.access(targetPath, fs.constants.F_OK);
@@ -211,17 +218,7 @@ async function resolveMcpLaunchSpec(): Promise {
logger.warn(`Packaged MCP entry not found at ${packagedEntry}, falling back to workspace`);
}
- // 2. Dev mode — prefer source for hot changes
- const sourceEntry = getSourceServerEntry();
- checked.push(sourceEntry);
- if (await pathExists(sourceEntry)) {
- return {
- command: 'pnpm',
- args: ['--dir', getWorkspaceMcpServerDir(), 'exec', 'tsx', sourceEntry],
- };
- }
-
- // 3. Dev mode — built dist
+ // 2. Dev mode — prefer built dist for reliable direct execution
const builtEntry = getBuiltServerEntry();
checked.push(builtEntry);
if (await pathExists(builtEntry)) {
@@ -231,6 +228,21 @@ async function resolveMcpLaunchSpec(): Promise {
};
}
+ // 3. Dev mode fallback — run source directly through a local tsx binary
+ const sourceEntry = getSourceServerEntry();
+ checked.push(sourceEntry);
+ if (await pathExists(sourceEntry)) {
+ for (const tsxBin of getWorkspaceTsxBinCandidates()) {
+ checked.push(tsxBin);
+ if (await pathExists(tsxBin)) {
+ return {
+ command: tsxBin,
+ args: [sourceEntry],
+ };
+ }
+ }
+ }
+
throw new Error(
`agent-teams-mcp entrypoint not found. Checked paths:\n${checked.map((p) => ` - ${p}`).join('\n')}`
);
diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts
index 92832d0e..d2295643 100644
--- a/src/main/services/team/TeamMemberResolver.ts
+++ b/src/main/services/team/TeamMemberResolver.ts
@@ -8,6 +8,7 @@ import type {
MemberStatus,
ResolvedTeamMember,
TeamConfig,
+ TeamMember,
TeamTaskWithKanban,
} from '@shared/types';
@@ -134,15 +135,26 @@ export class TeamMemberResolver {
if (Array.isArray(config.members)) {
for (const m of config.members) {
if (typeof m?.name === 'string' && m.name.trim() !== '') {
+ const configMember = m as TeamMember & { provider?: 'anthropic' | 'codex' | 'gemini' };
+ const providerId =
+ configMember.providerId === 'anthropic' ||
+ configMember.providerId === 'codex' ||
+ configMember.providerId === 'gemini'
+ ? configMember.providerId
+ : configMember.provider === 'anthropic' ||
+ configMember.provider === 'codex' ||
+ configMember.provider === 'gemini'
+ ? configMember.provider
+ : undefined;
configMemberMap.set(m.name.trim(), {
- agentType: m.agentType,
- role: m.role,
- workflow: m.workflow,
- providerId: m.providerId,
- model: m.model,
- effort: m.effort,
- color: m.color,
- cwd: m.cwd,
+ agentType: configMember.agentType,
+ role: configMember.role,
+ workflow: configMember.workflow,
+ providerId,
+ model: configMember.model,
+ effort: configMember.effort,
+ color: configMember.color,
+ cwd: configMember.cwd,
});
}
}
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 9841a248..6cb62dc6 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -295,6 +295,7 @@ interface ProvisioningRun {
onProgress: (progress: TeamProvisioningProgress) => void;
expectedMembers: string[];
request: TeamCreateRequest;
+ effectiveMembers: TeamCreateRequest['members'];
lastLogProgressAt: number;
/** Monotonic ms timestamp of last stdout/stderr data. For stall detection. */
lastDataReceivedAt: number;
@@ -491,6 +492,45 @@ function formatWorkflowBlock(workflow: string, indent: string): string {
return `\n${indent}---BEGIN WORKFLOW---\n${body}\n${indent}---END WORKFLOW---`;
}
+type TeamMemberInput = TeamCreateRequest['members'][number];
+
+function normalizeTeamMemberProviderId(providerId: unknown): TeamProviderId | undefined {
+ return providerId === 'codex' || providerId === 'gemini' || providerId === 'anthropic'
+ ? providerId
+ : undefined;
+}
+
+function buildEffectiveTeamMemberSpec(
+ member: TeamMemberInput,
+ defaults: {
+ providerId?: TeamProviderId;
+ model?: string;
+ effort?: TeamCreateRequest['effort'];
+ }
+): TeamMemberInput {
+ const memberProviderId = normalizeTeamMemberProviderId(member.providerId);
+ const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId);
+ const model = member.model?.trim() || defaults.model?.trim() || undefined;
+
+ return {
+ ...member,
+ providerId: memberProviderId ?? defaultProviderId ?? 'anthropic',
+ model,
+ effort: member.effort ?? defaults.effort,
+ };
+}
+
+function buildEffectiveTeamMemberSpecs(
+ members: TeamCreateRequest['members'],
+ defaults: {
+ providerId?: TeamProviderId;
+ model?: string;
+ effort?: TeamCreateRequest['effort'];
+ }
+): TeamCreateRequest['members'] {
+ return members.map((member) => buildEffectiveTeamMemberSpec(member, defaults));
+}
+
function buildMembersPrompt(members: TeamCreateRequest['members']): string {
return members
.map((member) => {
@@ -983,17 +1023,20 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
return `\nCurrent task board (in_progress/pending):\n${lines.join('\n')}\n`;
}
-function buildProvisioningPrompt(request: TeamCreateRequest): string {
+function buildProvisioningPrompt(
+ request: TeamCreateRequest,
+ effectiveMembers: TeamCreateRequest['members']
+): string {
const displayName = request.displayName?.trim() || request.teamName;
const userPromptBlock = request.prompt?.trim()
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
: '';
const leadName =
- request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
+ effectiveMembers.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
const projectName = path.basename(request.cwd);
- const isSolo = request.members.length === 0;
+ const isSolo = effectiveMembers.length === 0;
const step3Block = isSolo
? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself (“${leadName}”) as owner.\n` +
@@ -1036,14 +1079,15 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
With member_briefing bootstrap enabled, the teammate will fetch durable rules after spawn.
Per-member spawn instructions:
-${request.members
+${effectiveMembers
.map((m) => {
const providerLine =
m.providerId && m.providerId !== 'anthropic' ? ` - provider: “${m.providerId}”\n` : '';
const modelLine = m.model?.trim() ? ` - model: “${m.model.trim()}”\n` : '';
+ const effortLine = m.effort ? ` - effort: “${m.effort}”\n` : '';
return ` For “${m.name}”:
- name: “${m.name}”
-${providerLine}${modelLine} - prompt:
+${providerLine}${modelLine}${effortLine} - prompt:
${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName)
.split('\n')
.map((line) => ` ${line}`)
@@ -1055,7 +1099,7 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName)
teamName: request.teamName,
leadName,
isSolo,
- members: request.members,
+ members: effectiveMembers,
});
return `agent_teams_ui [Agent Team: “${request.teamName}” | Project: “${projectName}” | Lead: “${leadName}”] — team does NOT exist yet. You must create it.
@@ -3051,6 +3095,14 @@ export class TeamProvisioningService {
// Respawn with saved context — CLI handles its own auth refresh.
let child: ReturnType;
try {
+ if (mcpFlagIdx !== -1 && mcpFlagIdx + 1 < ctx.args.length) {
+ await this.validateAgentTeamsMcpRuntime(
+ ctx.claudePath,
+ ctx.cwd,
+ ctx.env,
+ ctx.args[mcpFlagIdx + 1]!
+ );
+ }
child = spawnCli(ctx.claudePath, ctx.args, {
cwd: ctx.cwd,
env: { ...ctx.env },
@@ -3292,6 +3344,11 @@ export class TeamProvisioningService {
throw new Error('Claude CLI not found; install it or provide a valid path');
}
+ const effectiveMemberSpecs = buildEffectiveTeamMemberSpecs(request.members, {
+ providerId: request.providerId,
+ model: request.model,
+ effort: request.effort,
+ });
const runId = randomUUID();
const startedAt = nowIso();
const run: ProvisioningRun = {
@@ -3318,6 +3375,7 @@ export class TeamProvisioningService {
onProgress,
expectedMembers: request.members.map((member) => member.name),
request,
+ effectiveMembers: effectiveMemberSpecs,
lastLogProgressAt: 0,
lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites)
lastStdoutReceivedAt: 0,
@@ -3372,13 +3430,14 @@ export class TeamProvisioningService {
this.provisioningRunByTeam.set(request.teamName, runId);
run.onProgress(run.progress);
- const prompt = buildProvisioningPrompt(request);
+ const prompt = buildProvisioningPrompt(request, effectiveMemberSpecs);
let child: ReturnType;
const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId);
let mcpConfigPath: string;
try {
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
run.mcpConfigPath = mcpConfigPath;
+ await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath);
} catch (error) {
this.runs.delete(runId);
this.provisioningRunByTeam.delete(request.teamName);
@@ -3718,12 +3777,20 @@ export class TeamProvisioningService {
const runId = randomUUID();
const startedAt = nowIso();
+ const effectiveMemberSpecs = buildEffectiveTeamMemberSpecs(expectedMemberSpecs, {
+ providerId: request.providerId,
+ model: request.model,
+ effort: request.effort,
+ });
+
// Build a synthetic TeamCreateRequest for reuse by shared infrastructure
const syntheticRequest: TeamCreateRequest = {
teamName: request.teamName,
members: expectedMemberSpecs,
cwd: request.cwd,
providerId: request.providerId,
+ model: request.model,
+ effort: request.effort,
skipPermissions: request.skipPermissions,
};
@@ -3764,6 +3831,7 @@ export class TeamProvisioningService {
onProgress,
expectedMembers,
request: syntheticRequest,
+ effectiveMembers: effectiveMemberSpecs,
lastLogProgressAt: 0,
lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites)
lastStdoutReceivedAt: 0,
@@ -3837,7 +3905,7 @@ export class TeamProvisioningService {
const prompt = buildLaunchPrompt(
request,
- expectedMemberSpecs,
+ effectiveMemberSpecs,
existingTasks,
Boolean(previousSessionId)
);
@@ -3847,6 +3915,7 @@ export class TeamProvisioningService {
try {
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
run.mcpConfigPath = mcpConfigPath;
+ await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath);
} catch (error) {
this.runs.delete(runId);
this.provisioningRunByTeam.delete(request.teamName);
@@ -6862,7 +6931,13 @@ export class TeamProvisioningService {
run.teamName,
run.request.cwd,
run.detectedSessionId,
- run.request.color
+ run.request.color,
+ {
+ providerId: run.request.providerId,
+ model: run.request.model,
+ effort: run.request.effort,
+ members: run.effectiveMembers,
+ }
);
await this.cleanupPrelaunchBackup(run.teamName);
@@ -6996,7 +7071,13 @@ export class TeamProvisioningService {
run.teamName,
run.request.cwd,
run.detectedSessionId,
- run.request.color
+ run.request.color,
+ {
+ providerId: run.request.providerId,
+ model: run.request.model,
+ effort: run.request.effort,
+ members: run.effectiveMembers,
+ }
);
// Clean up team.meta.json — provisioning succeeded, config.json is now authoritative.
@@ -7789,7 +7870,13 @@ export class TeamProvisioningService {
run.teamName,
run.request.cwd,
run.detectedSessionId,
- run.request.color
+ run.request.color,
+ {
+ providerId: run.request.providerId,
+ model: run.request.model,
+ effort: run.request.effort,
+ members: run.effectiveMembers,
+ }
);
// Process was killed by timeout — mark as disconnected, not ready
const progress = updateProgress(run, 'disconnected', 'Team provisioned but process timed out', {
@@ -7963,6 +8050,96 @@ export class TeamProvisioningService {
}
}
+ private applyEffectiveLaunchStateToConfig(
+ config: Record,
+ launchState?: {
+ providerId?: TeamProviderId;
+ model?: string;
+ effort?: TeamCreateRequest['effort'];
+ members?: TeamCreateRequest['members'];
+ }
+ ): void {
+ if (!launchState || !Array.isArray(config.members)) {
+ return;
+ }
+
+ const effectiveLeadProviderId =
+ normalizeTeamMemberProviderId(launchState.providerId) ?? 'anthropic';
+ const effectiveLeadModel = launchState.model?.trim() || undefined;
+ const effectiveLeadEffort =
+ launchState.effort === 'low' ||
+ launchState.effort === 'medium' ||
+ launchState.effort === 'high'
+ ? launchState.effort
+ : undefined;
+
+ const membersByName = new Map(
+ (launchState.members ?? []).map((member) => [member.name.toLowerCase(), member] as const)
+ );
+
+ config.members = (config.members as Record[]).map((member) => {
+ if (!member || typeof member !== 'object') {
+ return member;
+ }
+
+ const rawName = typeof member.name === 'string' ? member.name.trim() : '';
+ const nextMember = { ...member };
+
+ const assignRuntimeState = (state: {
+ providerId?: TeamProviderId;
+ model?: string;
+ effort?: TeamCreateRequest['effort'];
+ }): void => {
+ const providerId = normalizeTeamMemberProviderId(state.providerId);
+ if (providerId) {
+ nextMember.provider = providerId;
+ nextMember.providerId = providerId;
+ } else {
+ delete nextMember.provider;
+ delete nextMember.providerId;
+ }
+
+ const model = state.model?.trim() || undefined;
+ if (model) {
+ nextMember.model = model;
+ } else {
+ delete nextMember.model;
+ }
+
+ const effort =
+ state.effort === 'low' || state.effort === 'medium' || state.effort === 'high'
+ ? state.effort
+ : undefined;
+ if (effort) {
+ nextMember.effort = effort;
+ } else {
+ delete nextMember.effort;
+ }
+ };
+
+ if (isLeadMember(nextMember) || rawName.toLowerCase() === 'team-lead') {
+ assignRuntimeState({
+ providerId: effectiveLeadProviderId,
+ model: effectiveLeadModel,
+ effort: effectiveLeadEffort,
+ });
+ return nextMember;
+ }
+
+ const effectiveMember = membersByName.get(rawName.toLowerCase());
+ if (!effectiveMember) {
+ return nextMember;
+ }
+
+ assignRuntimeState({
+ providerId: effectiveMember.providerId,
+ model: effectiveMember.model,
+ effort: effectiveMember.effort,
+ });
+ return nextMember;
+ });
+ }
+
/**
* Single atomic read-mutate-write for post-launch config updates.
* Combines session history append and projectPath update to avoid
@@ -7972,7 +8149,13 @@ export class TeamProvisioningService {
teamName: string,
projectPath: string,
detectedSessionId: string | null,
- color?: string
+ color?: string,
+ launchState?: {
+ providerId?: TeamProviderId;
+ model?: string;
+ effort?: TeamCreateRequest['effort'];
+ members?: TeamCreateRequest['members'];
+ }
): Promise {
const MAX_SESSION_HISTORY = 5000;
const MAX_PROJECT_PATH_HISTORY = 500;
@@ -8049,6 +8232,8 @@ export class TeamProvisioningService {
: pathHistory;
}
+ this.applyEffectiveLaunchStateToConfig(config, launchState);
+
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
} catch (error) {
logger.warn(
@@ -8751,6 +8936,7 @@ export class TeamProvisioningService {
role?: string;
workflow?: string;
agentType?: string;
+ providerId?: string;
provider?: string;
model?: string;
effort?: string;
@@ -8771,10 +8957,7 @@ export class TeamProvisioningService {
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
workflow:
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
- providerId:
- member.provider === 'codex' || member.provider === 'gemini'
- ? member.provider
- : undefined,
+ providerId: normalizeTeamMemberProviderId(member.providerId ?? member.provider),
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
@@ -8985,6 +9168,56 @@ export class TeamProvisioningService {
return output;
}
+ private buildAgentTeamsMcpValidationError(output: string): string {
+ const detail = this.normalizeApiRetryErrorMessage(output) || output.trim();
+ if (!detail) {
+ return (
+ 'agent-teams MCP loaded config but did not expose member_briefing. ' +
+ 'The leader would start without required team MCP tools.'
+ );
+ }
+ return (
+ 'agent-teams MCP loaded config but did not expose member_briefing. ' + `Details: ${detail}`
+ );
+ }
+
+ private async validateAgentTeamsMcpRuntime(
+ claudePath: string,
+ cwd: string,
+ env: NodeJS.ProcessEnv,
+ mcpConfigPath: string
+ ): Promise {
+ const result = await this.spawnProbe(
+ claudePath,
+ [
+ '--setting-sources',
+ 'user,project,local',
+ '--mcp-config',
+ mcpConfigPath,
+ '--',
+ 'mcp',
+ 'get',
+ 'agent-teams',
+ ],
+ cwd,
+ env,
+ VERIFY_TIMEOUT_MS
+ );
+
+ const combinedOutput = buildCombinedLogs(result.stdout, result.stderr).trim();
+ if (result.exitCode !== 0) {
+ throw new Error(this.buildAgentTeamsMcpValidationError(combinedOutput));
+ }
+
+ const normalizedOutput = combinedOutput.toLowerCase();
+ if (
+ !normalizedOutput.includes('status: ✓ connected') &&
+ !normalizedOutput.includes('status: connected')
+ ) {
+ throw new Error(this.buildAgentTeamsMcpValidationError(combinedOutput));
+ }
+ }
+
private async spawnProbe(
claudePath: string,
args: string[],
diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts
index 0c74c587..5b14ca3c 100644
--- a/src/renderer/api/httpClient.ts
+++ b/src/renderer/api/httpClient.ts
@@ -1072,6 +1072,7 @@ export class HttpAPIClient implements ElectronAPI {
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
+ authStatusChecking: false,
authMethod: null,
providers: [],
}),
diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx
index 23ee9564..fa2256ac 100644
--- a/src/renderer/components/dashboard/CliStatusBanner.tsx
+++ b/src/renderer/components/dashboard/CliStatusBanner.tsx
@@ -125,8 +125,10 @@ const SLOW_CHECK_DELAY_MS = 5_000;
const CliCheckingSpinner = ({
styles,
+ label,
}: {
styles: { border: string; bg: string };
+ label: string;
}): React.JSX.Element => {
const [showHint, setShowHint] = useState(false);
@@ -146,7 +148,7 @@ const CliCheckingSpinner = ({
/>
- Checking AI Providers...
+ {label}
{showHint && (
@@ -309,6 +311,10 @@ function formatRuntimeAuthSummary(
return `Providers: ${connected}/${denominator} connected`;
}
+ if (cliStatus.authStatusChecking) {
+ return 'Checking authentication...';
+ }
+
if (cliStatus.authLoggedIn) {
return 'Authenticated';
}
@@ -316,6 +322,18 @@ function formatRuntimeAuthSummary(
return null;
}
+function isCheckingMultimodelStatus(
+ cliStatus: NonNullable['cliStatus']>
+): boolean {
+ return (
+ cliStatus.flavor === 'free-code' &&
+ cliStatus.providers.length > 0 &&
+ cliStatus.providers.every(
+ (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
+ )
+ );
+}
+
function createLoadingMultimodelStatus(): CliInstallationStatus {
const providers: CliProviderStatus[] = [
{ providerId: 'anthropic' as const, displayName: 'Anthropic' },
@@ -349,6 +367,7 @@ function createLoadingMultimodelStatus(): CliInstallationStatus {
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
+ authStatusChecking: true,
authMethod: null,
providers,
};
@@ -643,6 +662,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
async (enabled: boolean) => {
setIsSwitchingFlavor(true);
try {
+ useStore.setState({
+ cliStatus: enabled ? createLoadingMultimodelStatus() : null,
+ cliStatusLoading: true,
+ cliStatusError: null,
+ });
await updateConfig('general', { multimodelEnabled: enabled });
await invalidateCliStatus();
await fetchCliStatus();
@@ -695,6 +719,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
if (installerState === 'completed') return 'success';
if (installerState !== 'idle') return 'info';
if (!cliStatus) return 'loading';
+ if (isCheckingMultimodelStatus(cliStatus)) return 'info';
+ if (cliStatus.authStatusChecking) return 'info';
if (!cliStatus.installed) return 'error';
if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning';
if (cliStatus.updateAvailable) return 'info';
@@ -785,7 +811,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
}
// Claude-only mode: keep the generic loading spinner.
- return ;
+ return (
+
+ );
}
// ── Downloading ────────────────────────────────────────────────────────
@@ -937,8 +968,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
}
// Installed but not logged in — yellow warning banner
- if (cliStatus.installed && cliStatus.flavor !== 'free-code' && !cliStatus.authLoggedIn) {
- if (isVerifyingAuth) {
+ if (
+ cliStatus.installed &&
+ cliStatus.flavor !== 'free-code' &&
+ (cliStatus.authStatusChecking || isVerifyingAuth)
+ ) {
+ if (cliStatus.authStatusChecking || isVerifyingAuth) {
return (
{
);
}
+ }
+
+ if (
+ cliStatus.installed &&
+ cliStatus.flavor !== 'free-code' &&
+ !cliStatus.authStatusChecking &&
+ !cliStatus.authLoggedIn
+ ) {
return (
<>
({
...provider,
@@ -223,6 +224,11 @@ export const CliStatusSection = (): React.JSX.Element | null => {
async (enabled: boolean) => {
setIsSwitchingFlavor(true);
try {
+ useStore.setState({
+ cliStatus: enabled ? createLoadingMultimodelStatus() : null,
+ cliStatusLoading: true,
+ cliStatusError: null,
+ });
await updateConfig('general', { multimodelEnabled: enabled });
await invalidateCliStatus();
await fetchCliStatus();
@@ -261,7 +267,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
style={{ color: 'var(--color-text-muted)' }}
>
- Checking AI Providers...
+ {multimodelEnabled ? 'Checking AI Providers...' : 'Checking Claude CLI...'}
)}
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx
index 89369941..7579e4d6 100644
--- a/src/renderer/components/team/TeamDetailView.tsx
+++ b/src/renderer/components/team/TeamDetailView.tsx
@@ -1136,30 +1136,35 @@ export const TeamDetailView = ({
const teamSummary = teams.find((t) => t.teamName === teamName);
return (
<>
-
-
- Team not launched yet
-
- This is a draft team — {teamSummary?.displayName || teamName} has
- been configured with {teamSummary?.memberCount ?? 0} member
- {teamSummary?.memberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI
- yet. Click Launch to select a model and start the team.
-
-
-
-
+
+
+
+
+
+
+ Team not launched yet
+
+ This is a draft team — {teamSummary?.displayName || teamName} has
+ been configured with {teamSummary?.memberCount ?? 0} member
+ {teamSummary?.memberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI
+ yet. Click Launch to select a model and start the team.
+
+
+
+
+
diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx
index 4f10d048..b7d14a65 100644
--- a/src/renderer/components/team/TeamListView.tsx
+++ b/src/renderer/components/team/TeamListView.tsx
@@ -592,14 +592,14 @@ export const TeamListView = (): React.JSX.Element => {
setLaunchingTeamName(request.teamName);
try {
await launchTeam(request);
- openTeamTab(request.teamName, request.cwd);
} catch (err) {
console.error('Failed to launch team:', err);
+ throw err;
} finally {
setLaunchingTeamName(null);
}
},
- [launchTeam, openTeamTab]
+ [launchTeam]
);
useEffect(() => {
diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
index a6689fb0..faf44f48 100644
--- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
@@ -7,6 +7,8 @@ import {
buildMembersFromDrafts,
clearMemberModelOverrides,
createMemberDraft,
+ normalizeMemberDraftForProviderMode,
+ normalizeProviderForMode,
validateMemberNameInline,
} from '@renderer/components/team/members/MembersEditorSection';
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
@@ -33,6 +35,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
+import { useStore } from '@renderer/store';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
@@ -266,6 +269,7 @@ export const CreateTeamDialog = ({
onOpenTeam,
}: CreateTeamDialogProps): React.JSX.Element => {
const { isLight } = useTheme();
+ const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
// ── Persisted draft state (survives tab navigation) ──────────────────
const {
@@ -362,13 +366,14 @@ export const CreateTeamDialog = ({
};
const setSelectedProviderId = (value: TeamProviderId): void => {
- setSelectedProviderIdRaw(value);
- localStorage.setItem('team:lastSelectedProvider', value);
- if (value !== 'anthropic') {
+ const normalizedValue = normalizeProviderForMode(value, multimodelEnabled);
+ setSelectedProviderIdRaw(normalizedValue);
+ localStorage.setItem('team:lastSelectedProvider', normalizedValue);
+ if (normalizedValue !== 'anthropic') {
setLimitContextRaw(false);
localStorage.setItem('team:lastLimitContext', 'false');
}
- setSelectedModelRaw(getStoredTeamModel(value));
+ setSelectedModelRaw(getStoredTeamModel(normalizedValue));
};
const setLimitContext = (value: boolean): void => {
@@ -444,7 +449,10 @@ export const CreateTeamDialog = ({
[members, syncModelsWithLead]
);
- const selectedMemberProviders = useMemo(() => {
+ const selectedMemberProviders = useMemo(() => {
+ if (!multimodelEnabled) {
+ return ['anthropic'];
+ }
if (soloTeam || syncModelsWithLead) {
return [selectedProviderId];
}
@@ -456,7 +464,22 @@ export const CreateTeamDialog = ({
),
])
);
- }, [members, selectedProviderId, soloTeam, syncModelsWithLead]);
+ }, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]);
+
+ useEffect(() => {
+ if (multimodelEnabled) {
+ return;
+ }
+ if (selectedProviderId !== 'anthropic') {
+ setSelectedProviderIdRaw('anthropic');
+ setSelectedModelRaw(getStoredTeamModel('anthropic'));
+ }
+ const nextMembers = members.map((member) => normalizeMemberDraftForProviderMode(member, false));
+ const changed = nextMembers.some((member, index) => member !== members[index]);
+ if (changed) {
+ setMembers(nextMembers);
+ }
+ }, [members, multimodelEnabled, selectedProviderId, setMembers]);
useEffect(() => {
if (!open || !canCreate || !launchTeam) {
@@ -631,15 +654,22 @@ export const CreateTeamDialog = ({
const presetRoles: readonly string[] = PRESET_ROLES;
const isPreset = m.role != null && presetRoles.includes(m.role);
const isCustom = m.role != null && m.role.length > 0 && !isPreset;
- return createMemberDraft({
- name: m.name,
- roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
- customRole: isCustom ? m.role : '',
- workflow: m.workflow,
- providerId: m.providerId,
- model: m.model ?? '',
- effort: m.effort,
- });
+ return normalizeMemberDraftForProviderMode(
+ createMemberDraft({
+ name: m.name,
+ roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
+ customRole: isCustom ? m.role : '',
+ workflow: m.workflow,
+ providerId: normalizeProviderForMode(m.providerId, multimodelEnabled),
+ model:
+ normalizeProviderForMode(m.providerId, multimodelEnabled) ===
+ normalizeProviderForMode(m.providerId, true)
+ ? (m.model ?? '')
+ : '',
+ effort: m.effort,
+ }),
+ multimodelEnabled
+ );
})
);
setSyncModelsWithLead(
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
index ed22b46b..978b1658 100644
--- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
@@ -7,9 +7,12 @@ import {
buildMembersFromDrafts,
clearMemberModelOverrides,
createMemberDraftsFromInputs,
+ normalizeMemberDraftForProviderMode,
+ normalizeProviderForMode,
validateMemberNameInline,
} from '@renderer/components/team/members/MembersEditorSection';
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
+import { TeamProvisioningBanner } from '@renderer/components/team/TeamProvisioningBanner';
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
@@ -33,6 +36,10 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
+import {
+ getCurrentProvisioningProgressForTeam,
+ isTeamProvisioningActive,
+} from '@renderer/store/slices/teamSlice';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor';
import {
@@ -155,6 +162,7 @@ function getProviderLabel(providerId: TeamProviderId): string {
export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Element => {
const { open, onClose } = props;
const { isLight } = useTheme();
+ const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const isLaunch = props.mode === 'launch';
const isSchedule = props.mode === 'schedule';
const schedule = isSchedule ? (props.schedule ?? null) : null;
@@ -261,21 +269,42 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
() => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts),
[membersDrafts, syncModelsWithLead]
);
- const selectedMemberProviders = useMemo(
+ const selectedMemberProviders = useMemo(
() =>
- Array.from(
- new Set([
- selectedProviderId,
- ...effectiveMemberDrafts.flatMap((member) =>
- member.providerId === 'codex' || member.providerId === 'gemini'
- ? [member.providerId]
- : []
+ !multimodelEnabled
+ ? ['anthropic']
+ : Array.from(
+ new Set([
+ selectedProviderId,
+ ...effectiveMemberDrafts.flatMap((member) =>
+ member.providerId === 'codex' || member.providerId === 'gemini'
+ ? [member.providerId]
+ : []
+ ),
+ ])
),
- ])
- ),
- [effectiveMemberDrafts, selectedProviderId]
+ [effectiveMemberDrafts, multimodelEnabled, selectedProviderId]
);
+ useEffect(() => {
+ if (multimodelEnabled) {
+ return;
+ }
+ if (selectedProviderId !== 'anthropic') {
+ setSelectedProviderIdRaw('anthropic');
+ setSelectedModelRaw(getStoredTeamModel('anthropic'));
+ }
+ setMembersDrafts((prev) => {
+ let changed = false;
+ const next = prev.map((member) => {
+ const normalized = normalizeMemberDraftForProviderMode(member, false);
+ if (normalized !== member) changed = true;
+ return normalized;
+ });
+ return changed ? next : prev;
+ });
+ }, [multimodelEnabled, selectedProviderId]);
+
// Schedule store actions
const createSchedule = useStore((s) => s.createSchedule);
const updateSchedule = useStore((s) => s.updateSchedule);
@@ -302,13 +331,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
};
const setSelectedProviderId = (value: TeamProviderId): void => {
- setSelectedProviderIdRaw(value);
- localStorage.setItem('team:lastSelectedProvider', value);
- if (value !== 'anthropic') {
+ const normalizedValue = normalizeProviderForMode(value, multimodelEnabled);
+ setSelectedProviderIdRaw(normalizedValue);
+ localStorage.setItem('team:lastSelectedProvider', normalizedValue);
+ if (normalizedValue !== 'anthropic') {
setLimitContextRaw(false);
localStorage.setItem('team:lastLimitContext', 'false');
}
- setSelectedModelRaw(getStoredTeamModel(value));
+ setSelectedModelRaw(getStoredTeamModel(normalizedValue));
};
const setSelectedModel = (value: string): void => {
@@ -401,8 +431,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
promptDraft.setValue(schedule.launchConfig.prompt);
setCustomCwd(schedule.launchConfig.cwd);
setCwdMode('custom');
- setSelectedProviderIdRaw(schedule.launchConfig.providerId ?? 'anthropic');
- setSelectedModelRaw(schedule.launchConfig.model ?? '');
+ const scheduleProviderId = normalizeProviderForMode(
+ schedule.launchConfig.providerId,
+ multimodelEnabled
+ );
+ setSelectedProviderIdRaw(scheduleProviderId);
+ setSelectedModelRaw(
+ scheduleProviderId === normalizeProviderForMode(schedule.launchConfig.providerId, true)
+ ? (schedule.launchConfig.model ?? '')
+ : getStoredTeamModel('anthropic')
+ );
setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false);
setSelectedEffortRaw(schedule.launchConfig.effort ?? '');
} else {
@@ -417,8 +455,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setCwdMode('project');
setSelectedProjectPath('');
setCustomCwd('');
- setSelectedProviderIdRaw(getStoredTeamProvider());
- setSelectedModelRaw(getStoredTeamModel(getStoredTeamProvider()));
+ const storedProviderId = normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled);
+ setSelectedProviderIdRaw(storedProviderId);
+ setSelectedModelRaw(getStoredTeamModel(storedProviderId));
setSelectedEffortRaw('medium');
}
@@ -442,10 +481,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}
if (cancelled) return;
- const nextProviderId =
+ const rawNextProviderId =
savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini'
? savedRequest.providerId
: 'anthropic';
+ const nextProviderId = normalizeProviderForMode(rawNextProviderId, multimodelEnabled);
const providerFromSaved = Boolean(savedRequest?.providerId);
const nextMembersSource =
members.length > 0
@@ -455,15 +495,28 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
: [];
const storedEffort = localStorage.getItem('team:lastSelectedEffort');
- setMembersDrafts(createMemberDraftsFromInputs(nextMembersSource));
+ setMembersDrafts(
+ createMemberDraftsFromInputs(nextMembersSource).map((member) =>
+ normalizeMemberDraftForProviderMode(member, multimodelEnabled)
+ )
+ );
setSyncModelsWithLead(
!nextMembersSource.some((member) => member.providerId || member.model || member.effort)
);
- setSelectedProviderIdRaw(providerFromSaved ? nextProviderId : getStoredTeamProvider());
+ setSelectedProviderIdRaw(
+ providerFromSaved
+ ? nextProviderId
+ : normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled)
+ );
setSelectedModelRaw(
- typeof savedRequest?.model === 'string'
+ typeof savedRequest?.model === 'string' &&
+ nextProviderId === normalizeProviderForMode(rawNextProviderId, true)
? savedRequest.model
- : getStoredTeamModel(providerFromSaved ? nextProviderId : getStoredTeamProvider())
+ : getStoredTeamModel(
+ providerFromSaved
+ ? nextProviderId
+ : normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled)
+ )
);
setSelectedEffortRaw(
savedRequest?.effort ?? (storedEffort === null ? 'medium' : storedEffort)
@@ -481,7 +534,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return () => {
cancelled = true;
};
- }, [open, isLaunch, effectiveTeamName, members]);
+ }, [open, isLaunch, effectiveTeamName, members, multimodelEnabled]);
// ---------------------------------------------------------------------------
// Launch-only effects
@@ -773,6 +826,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const provisioningError = isLaunch ? props.provisioningError : null;
const activeError = localError ?? provisioningError;
+ const currentProvisioning = useStore((s) =>
+ isLaunch && effectiveTeamName
+ ? getCurrentProvisioningProgressForTeam(s, effectiveTeamName)
+ : null
+ );
+ const launchInFlight = useStore((s) =>
+ isLaunch && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false
+ );
// ---------------------------------------------------------------------------
// Submit
@@ -827,8 +888,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
extraCliArgs: customArgs.trim() || undefined,
});
- resetFormState();
- onClose();
} else {
// Schedule mode: create or update
const parsedBudget = maxBudgetUsd ? parseFloat(maxBudgetUsd) : undefined;
@@ -883,7 +942,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
// ---------------------------------------------------------------------------
const isDisabled = isLaunch
- ? isSubmitting || prepareState !== 'ready'
+ ? isSubmitting || launchInFlight
: isSubmitting || validationErrors.length > 0;
// ---------------------------------------------------------------------------
@@ -905,7 +964,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
'Schedule automatic Claude task execution'
);
- const submitLabel = isLaunch ? 'Launch' : isEditing ? 'Save Changes' : 'Create Schedule';
+ const submitLabel = isLaunch
+ ? prepareState === 'idle' || prepareState === 'loading'
+ ? 'Skip and Launch'
+ : 'Launch'
+ : isEditing
+ ? 'Save Changes'
+ : 'Create Schedule';
const submittingLabel = isLaunch ? 'Launching...' : isEditing ? 'Saving...' : 'Creating...';
@@ -1160,38 +1225,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
projectsError={projectsError}
/>
- {isLaunch ? (
-
- ) : null}
-
{/* ═══════════════════════════════════════════════════════════════════
Launch: optional settings
Schedule: prompt + execution defaults
@@ -1203,6 +1236,38 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
summary={launchOptionalSummary}
>
+
+
) : null}
+ {isLaunch && effectiveTeamName && (currentProvisioning || provisioningError) ? (
+
+
+
+ ) : null}
+
{/* Launch-only: CLI warm-up status */}
{isLaunch ? (
@@ -1405,13 +1476,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
Pre-flight check to catch errors before launch
-
@@ -1462,7 +1526,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
disabled={isDisabled}
onClick={handleSubmit}
>
- {isSubmitting ? (
+ {isSubmitting || launchInFlight ? (
<>
{submittingLabel}
diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx
index c1003bcb..654c4385 100644
--- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx
+++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx
@@ -160,7 +160,8 @@ export const TeamModelSelector: React.FC = ({
id,
}) => {
const cliStatus = useStore((s) => s.cliStatus);
- const multimodelAvailable = cliStatus?.flavor === 'free-code';
+ const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
+ const multimodelAvailable = multimodelEnabled || cliStatus?.flavor === 'free-code';
const [dropdownOpen, setDropdownOpen] = useState(false);
const containerRef = useRef(null);
@@ -180,6 +181,12 @@ export const TeamModelSelector: React.FC = ({
const activeProvider = PROVIDERS.find((provider) => provider.id === providerId) ?? PROVIDERS[0];
const ProviderIcon = activeProvider.icon;
+ const defaultModelTooltip = useMemo(() => {
+ if (providerId === 'anthropic') {
+ return 'Default model from Claude CLI (/model).\nUses the runtime default for the selected provider.';
+ }
+ return 'Uses the runtime default for the selected provider.';
+ }, [providerId]);
const isProviderSelectable = (candidateProviderId: string): boolean =>
multimodelAvailable || candidateProviderId === 'anthropic';
const activeProviderSelectable = isProviderSelectable(providerId);
@@ -192,7 +199,7 @@ export const TeamModelSelector: React.FC = ({
: providerId === 'gemini'
? GEMINI_MODEL_OPTIONS
: ANTHROPIC_MODEL_OPTIONS;
- if (runtimeModels.length === 0) {
+ if (providerId === 'anthropic' || runtimeModels.length === 0) {
return [...fallback];
}
const dynamicOptions = runtimeModels.map((model) => ({
@@ -310,7 +317,7 @@ export const TeamModelSelector: React.FC = ({
)}
{modelOptions.map((opt) => (
@@ -342,9 +349,12 @@ export const TeamModelSelector: React.FC = ({
- Default model from Claude CLI (/model).
-
- Uses the runtime default for the selected provider.
+ {defaultModelTooltip.split('\n').map((line, index) => (
+
+ {index > 0 ?
: null}
+ {line}
+
+ ))}
diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx
index 3321f7cc..2bc85aea 100644
--- a/src/renderer/components/team/members/LeadModelRow.tsx
+++ b/src/renderer/components/team/members/LeadModelRow.tsx
@@ -49,7 +49,7 @@ export const LeadModelRow = ({
return (
-
-
- lead
+
+
+ lead
+ Team Lead
-
-
- Team Lead
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
{modelExpanded ? (
-
+ {providerId === 'anthropic' ? (
+
+ ) : null}
diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx
index 1cff48df..29d4c588 100644
--- a/src/renderer/components/team/members/MemberDraftRow.tsx
+++ b/src/renderer/components/team/members/MemberDraftRow.tsx
@@ -9,13 +9,14 @@ import { RoleSelect } from '@renderer/components/team/RoleSelect';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTheme } from '@renderer/hooks/useTheme';
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { getMemberColorByName } from '@shared/constants/memberColors';
-import { ChevronDown, ChevronRight, Info, Trash2 } from 'lucide-react';
+import { ChevronDown, ChevronRight, Info, RotateCcw, Trash2 } from 'lucide-react';
import type { MemberDraft } from './membersEditorTypes';
import type { InlineChip } from '@renderer/types/inlineChip';
@@ -48,6 +49,8 @@ interface MemberDraftRowProps {
lockProviderModel?: boolean;
forceInheritedModelSettings?: boolean;
modelLockReason?: string;
+ isRemoved?: boolean;
+ onRestore?: (id: string) => void;
}
export const MemberDraftRow = ({
@@ -76,6 +79,8 @@ export const MemberDraftRow = ({
lockProviderModel = false,
forceInheritedModelSettings = false,
modelLockReason,
+ isRemoved = false,
+ onRestore,
}: MemberDraftRowProps): React.JSX.Element => {
const { isLight } = useTheme();
const memberColorSet = getTeamColorSet(
@@ -154,13 +159,19 @@ export const MemberDraftRow = ({
const effectiveEffort = forceInheritedModelSettings
? inheritedEffort
: (member.effort ?? inheritedEffort);
- const modelButtonLabel = effectiveModel?.trim()
+ const modelButtonLabelBase = effectiveModel?.trim()
? getTeamModelLabel(effectiveModel.trim())
: 'Default';
+ const modelButtonLabel = forceInheritedModelSettings
+ ? `${modelButtonLabelBase} (lead)`
+ : modelButtonLabelBase;
+ const modelTooltipText = forceInheritedModelSettings
+ ? 'Provider, model, and effort are inherited from the lead while sync is enabled.'
+ : modelLockReason;
return (
onNameChange(member.id, event.target.value)}
placeholder="member-name"
style={
@@ -193,6 +205,7 @@ export const MemberDraftRow = ({
onRoleChange(member.id, roleSelection)}
customRole={member.customRole}
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
@@ -207,6 +220,7 @@ export const MemberDraftRow = ({
variant="outline"
size="sm"
className="relative h-8 shrink-0 gap-1"
+ disabled={isRemoved}
onClick={() => setWorkflowExpanded((prev) => !prev)}
>
{workflowExpanded ? (
@@ -221,36 +235,62 @@ export const MemberDraftRow = ({
) : null}
+
+
+
+
+
+
+ {modelTooltipText ? (
+
+ {modelTooltipText}
+
+ ) : null}
+
+
+ {isRemoved ? (
-
-
+ ) : (
+
+ )}
+ {isRemoved ? (
+ Removed
+ ) : null}
{showWorkflow && onWorkflowChange && workflowExpanded ? (
-
+