chore: commit remaining workspace updates

This commit is contained in:
777genius 2026-05-21 16:43:00 +03:00
parent 420ace3c15
commit 6fd75c6704
40 changed files with 2972 additions and 1529 deletions

View file

@ -586,6 +586,25 @@
opacity: 0.62;
}
.cyber-action-button--primary .cyber-action-button__glow::after {
position: absolute;
inset: -42% -58%;
content: "";
background:
linear-gradient(
108deg,
transparent 0 42%,
rgba(255, 255, 255, 0.18) 47%,
rgba(255, 255, 255, 0.58) 50%,
rgba(0, 234, 255, 0.24) 53%,
transparent 59% 100%
);
mix-blend-mode: screen;
opacity: 0;
transform: translate3d(-64%, 0, 0) skewX(-18deg);
animation: cyberActionButtonShine 4.8s cubic-bezier(0.25, 0.1, 0.22, 1) infinite;
}
.cyber-action-button__frame {
position: absolute;
inset: 0;
@ -1717,6 +1736,28 @@
}
}
@keyframes cyberActionButtonShine {
0%,
54% {
opacity: 0;
transform: translate3d(-64%, 0, 0) skewX(-18deg);
}
61% {
opacity: 0.8;
}
76% {
opacity: 0.5;
}
84%,
100% {
opacity: 0;
transform: translate3d(64%, 0, 0) skewX(-18deg);
}
}
@media (max-width: 1280px) {
.cyber-hero__layout {
grid-template-columns: minmax(500px, 0.9fr) minmax(0, 1.1fr);

View file

@ -39,7 +39,7 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
const resolvedImage = computed<PageSeoImage>(() => {
if (options.image) return options.image;
return {
url: "/og-image.png",
url: "/og-image-agent-teams-v5.png",
width: 1200,
height: 630,
type: "image/png",

View file

@ -7,9 +7,34 @@ const props = defineProps<{
}>();
const { t } = useI18n();
const config = useRuntimeConfig();
const siteUrl = ((config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai").replace(/\/+$/, "");
const ogImage = `${siteUrl}/og-image-agent-teams-v5.png`;
const statusCode = computed(() => props.error?.statusCode || 404);
const isNotFound = computed(() => statusCode.value === 404);
const errorTitle = computed(() => (isNotFound.value ? t("error.notFoundTitle") : t("error.genericTitle")));
const errorDescription = computed(() => (isNotFound.value ? t("error.notFoundDescription") : t("error.genericDescription")));
useSeoMeta({
title: errorTitle,
description: errorDescription,
robots: "noindex, nofollow",
ogTitle: errorTitle,
ogDescription: errorDescription,
ogType: "website",
ogSiteName: "Agent Teams",
ogImage,
ogImageType: "image/png",
ogImageWidth: "1200",
ogImageHeight: "630",
ogImageAlt: "Agent Teams - AI agent orchestration",
twitterCard: "summary_large_image",
twitterTitle: errorTitle,
twitterDescription: errorDescription,
twitterImage: ogImage,
twitterImageAlt: "Agent Teams - AI agent orchestration"
});
const handleGoHome = () => clearError({ redirect: "/" });
</script>

View file

@ -12,6 +12,9 @@ const muxPlaybackId = process.env.NUXT_PUBLIC_MUX_PLAYBACK_ID || "qyeNuDjFqoDALK
const muxBackgroundPlaybackId = process.env.NUXT_PUBLIC_MUX_BACKGROUND_PLAYBACK_ID || muxPlaybackId;
const baseURL = process.env.NUXT_APP_BASE_URL || "/";
const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`;
const defaultSeoTitle = "Agent Teams - AI Agent Orchestration for Developers";
const defaultSeoDescription = "Free, open-source desktop app for AI agent teams. Start with a free model with no auth, then connect Claude, Codex, or OpenCode when you need more models.";
const defaultSeoImage = `${siteUrl.replace(/\/+$/, "")}/og-image-agent-teams-v5.png`;
export default defineNuxtConfig({
compatibilityDate: "2026-01-19",
@ -20,6 +23,25 @@ export default defineNuxtConfig({
app: {
baseURL,
head: {
title: defaultSeoTitle,
meta: [
{ name: "description", content: defaultSeoDescription },
{ name: "robots", content: "noindex, nofollow" },
{ property: "og:title", content: defaultSeoTitle },
{ property: "og:description", content: defaultSeoDescription },
{ property: "og:type", content: "website" },
{ property: "og:site_name", content: "Agent Teams" },
{ property: "og:image", content: defaultSeoImage },
{ property: "og:image:type", content: "image/png" },
{ property: "og:image:width", content: "1200" },
{ property: "og:image:height", content: "630" },
{ property: "og:image:alt", content: "Agent Teams - AI agent orchestration" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: defaultSeoTitle },
{ name: "twitter:description", content: defaultSeoDescription },
{ name: "twitter:image", content: defaultSeoImage },
{ name: "twitter:image:alt", content: "Agent Teams - AI agent orchestration" }
],
link: [
{ rel: "icon", type: "image/x-icon", href: `${baseURL}favicon.ico` },
{ rel: "icon", type: "image/png", sizes: "32x32", href: `${baseURL}favicon-32.png` },

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 673 KiB

View file

@ -44,6 +44,7 @@ import type {
} from '../hooks/useRuntimeProviderManagement';
import type {
RuntimeProviderConnectionDto,
RuntimeProviderDefaultModelSourceDto,
RuntimeProviderDefaultScopeDto,
RuntimeProviderDirectoryEntryDto,
RuntimeProviderModelDto,
@ -140,14 +141,48 @@ function getProjectContextName(projectPath: string | null | undefined): string |
function getDefaultScopeDescription(scope: RuntimeProviderDefaultScopeDto): string {
return scope === 'all_projects'
? 'Used by project contexts without their own OpenCode default. Local models are tested per project.'
: 'Applies only to the selected project context.';
? 'Default for every project that does not have its own OpenCode override.'
: 'Override only the selected project. Running teams are not changed.';
}
function getDefaultScopeButtonLabel(scope: RuntimeProviderDefaultScopeDto): string {
return scope === 'all_projects' ? 'Set all-projects default' : 'Set project default';
}
function getContextControlLabel(scope: RuntimeProviderDefaultScopeDto): string {
return scope === 'all_projects' ? 'Validation context' : 'Project override context';
}
function getContextControlHint(
scope: RuntimeProviderDefaultScopeDto,
projectPath: string | null | undefined
): string {
const projectName = getProjectContextName(projectPath) ?? projectPath?.trim();
if (!projectName) {
return 'Select a project before testing local models or saving defaults.';
}
return scope === 'all_projects'
? `Tests use ${projectName}. Default applies unless a project has an override.`
: `Saving overrides only ${projectName}.`;
}
function getDefaultModelSourceLabel(
source: RuntimeProviderDefaultModelSourceDto | null | undefined
): string | null {
switch (source) {
case 'project':
return 'project override';
case 'all_projects':
return 'all projects';
case 'opencode_config':
return 'OpenCode config';
case 'fallback':
return 'fallback';
default:
return null;
}
}
function isDefaultForScope(
model: RuntimeProviderModelDto,
state: RuntimeProviderManagementState,
@ -443,12 +478,12 @@ function RuntimeSummary({
state,
onRefresh,
disabled,
projectPath,
}: Pick<RuntimeProviderManagementPanelViewProps, 'state' | 'disabled' | 'projectPath'> & {
}: Pick<RuntimeProviderManagementPanelViewProps, 'state' | 'disabled'> & {
onRefresh: () => void;
}): JSX.Element {
const runtime = state.view?.runtime;
const loadingWithoutRuntime = state.loading && !runtime;
const defaultSourceLabel = getDefaultModelSourceLabel(state.view?.defaultModelSource);
return (
<div
className="rounded-lg border p-3"
@ -482,19 +517,9 @@ function RuntimeSummary({
OpenCode default: {state.view.defaultModel}
</span>
) : null}
</div>
<div
className="mt-1 truncate text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
title={
projectPath
? `Current project context: ${projectPath}`
: 'No project context selected; using the fallback OpenCode management context.'
}
>
{projectPath
? `Project context: ${getProjectContextName(projectPath) ?? 'current project'}`
: 'No project context selected'}
{defaultSourceLabel ? (
<span style={{ color: 'var(--color-text-muted)' }}>Source: {defaultSourceLabel}</span>
) : null}
</div>
{state.loading ? (
<div
@ -1208,6 +1233,28 @@ function getOpenCodeRouteUnavailableTitle(model: RuntimeProviderModelDto): strin
return undefined;
}
function getOpenCodeModelSearchText(model: RuntimeProviderModelDto): string {
const recommendation = getOpenCodeTeamModelRecommendation(model.modelId);
return [
model.providerId,
model.modelId,
model.displayName,
model.sourceLabel,
model.accessKind,
model.routeKind,
model.proofState,
model.availability,
model.accessReason ?? '',
model.free ? 'free' : '',
model.default ? 'default' : '',
model.requiresExecutionProof ? 'needs test needs probe' : '',
recommendation?.label ?? '',
recommendation?.level ?? '',
]
.join(' ')
.toLowerCase();
}
function ModelResult({
result,
}: {
@ -1370,6 +1417,11 @@ function OpenCodeModelScopeControls({
}
return options;
}, [projectPath, projects]);
const contextPlaceholder = loading
? 'Loading contexts...'
: defaultScope === 'all_projects'
? 'Select validation context'
: 'Select project context';
return (
<div
@ -1381,13 +1433,13 @@ function OpenCodeModelScopeControls({
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text)]">OpenCode model scope</div>
<div className="text-sm font-medium text-[var(--color-text)]">OpenCode defaults</div>
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
{getDefaultScopeDescription(defaultScope)}
</div>
</div>
<div className="inline-flex shrink-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{(['project', 'all_projects'] as const).map((scope) => (
{(['all_projects', 'project'] as const).map((scope) => (
<button
key={scope}
type="button"
@ -1404,9 +1456,11 @@ function OpenCodeModelScopeControls({
</div>
</div>
<div className="mt-3 grid gap-2 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
<div className="mt-3">
<div className="min-w-0">
<Label className="text-xs text-[var(--color-text-secondary)]">Project context</Label>
<Label className="text-xs text-[var(--color-text-secondary)]">
{getContextControlLabel(defaultScope)}
</Label>
<div className="mt-1">
<Select
value={selectedValue}
@ -1416,12 +1470,10 @@ function OpenCodeModelScopeControls({
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue
placeholder={loading ? 'Loading project contexts...' : 'Select project context'}
/>
<SelectValue placeholder={contextPlaceholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PROJECT_CONTEXT_VALUE}>Select project context</SelectItem>
<SelectItem value={NO_PROJECT_CONTEXT_VALUE}>{contextPlaceholder}</SelectItem>
{projectOptions.map((project) => (
<SelectItem key={project.path} value={project.path}>
{project.name || getProjectContextName(project.path) || project.path}
@ -1432,12 +1484,10 @@ function OpenCodeModelScopeControls({
</div>
</div>
<div
className="min-w-0 truncate text-[11px] text-[var(--color-text-muted)] md:max-w-[280px]"
className="mt-1 text-[11px] leading-4 text-[var(--color-text-muted)]"
title={projectPath?.trim() || undefined}
>
{projectPath
? `Current context: ${getProjectContextName(projectPath) ?? projectPath}`
: 'Select a project before testing or saving OpenCode defaults.'}
{getContextControlHint(defaultScope, projectPath)}
</div>
</div>
@ -1463,7 +1513,16 @@ function ConfiguredOpenCodeModelsPanel({
readonly defaultScope: RuntimeProviderDefaultScopeDto;
readonly hasProjectContext: boolean;
}): JSX.Element | null {
const models = state.view?.configuredModels ?? [];
const models = useMemo(() => state.view?.configuredModels ?? [], [state.view?.configuredModels]);
const [query, setQuery] = useState('');
const normalizedQuery = query.trim().toLowerCase();
const visibleModels = useMemo(
() =>
normalizedQuery
? models.filter((model) => getOpenCodeModelSearchText(model).includes(normalizedQuery))
: models,
[models, normalizedQuery]
);
if (models.length === 0) {
return null;
}
@ -1486,10 +1545,25 @@ function ConfiguredOpenCodeModelsPanel({
current default.
</div>
</div>
<div className="relative min-w-[220px] flex-1 sm:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-text-muted)]" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search model routes"
className="h-9 pl-10 pr-3 text-sm leading-5"
style={{ paddingLeft: 40 }}
/>
</div>
</div>
<div className="mt-3 space-y-2">
{models.map((model) => {
{visibleModels.length === 0 ? (
<div className="rounded-md border border-dashed border-white/10 px-3 py-3 text-sm text-[var(--color-text-muted)]">
No OpenCode model routes match {query.trim()}.
</div>
) : null}
{visibleModels.map((model) => {
const selected = state.selectedModelId === model.modelId;
const testing = state.testingModelIds.includes(model.modelId);
const savingDefault = state.savingDefaultModelId === model.modelId;
@ -1729,7 +1803,7 @@ export function RuntimeProviderManagementPanelView({
onProjectContextChange,
}: RuntimeProviderManagementPanelViewProps): JSX.Element {
const [selectedSection, setSelectedSection] = useState<OpenCodeSettingsSection | null>(null);
const [defaultScope, setDefaultScope] = useState<RuntimeProviderDefaultScopeDto>('project');
const [defaultScope, setDefaultScope] = useState<RuntimeProviderDefaultScopeDto>('all_projects');
const providerQuery = state.providerQuery.trim().toLowerCase();
const filteredProviders = providerQuery
? state.providers.filter((provider) =>
@ -1759,17 +1833,14 @@ export function RuntimeProviderManagementPanelView({
? 'OpenCode provider catalog'
: 'OpenCode providers';
const launchableModelCount = state.view?.configuredModels?.length ?? 0;
const activeSection = selectedSection ?? (launchableModelCount > 0 ? 'models' : 'providers');
const modelsLoading = state.loading && launchableModelCount === 0;
const activeSection =
selectedSection ?? (modelsLoading || launchableModelCount > 0 ? 'models' : 'providers');
const hasProjectContext = Boolean(projectPath?.trim());
return (
<div className="space-y-3">
<RuntimeSummary
state={state}
disabled={disabled}
projectPath={projectPath}
onRefresh={() => void actions.refresh()}
/>
<RuntimeSummary state={state} disabled={disabled} onRefresh={() => void actions.refresh()} />
{state.error ? (
<div
@ -1847,7 +1918,22 @@ export function RuntimeProviderManagementPanelView({
defaultScope={defaultScope}
hasProjectContext={hasProjectContext}
/>
{launchableModelCount === 0 ? (
{modelsLoading ? (
<div
className="rounded-lg border p-3"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255,255,255,0.02)',
}}
>
<div className="mb-3 flex items-center gap-2 text-sm text-[var(--color-text-secondary)]">
<Loader2 className="size-3.5 animate-spin" />
Loading OpenCode model routes...
</div>
<RuntimeProviderModelLoadingSkeleton />
</div>
) : null}
{!modelsLoading && launchableModelCount === 0 ? (
<div className="rounded-lg border border-dashed border-white/10 p-4 text-sm text-[var(--color-text-muted)]">
No launchable OpenCode model routes were reported yet. Configure a local route in
OpenCode or use the Providers tab to inspect catalog providers.

View file

@ -108,6 +108,11 @@ export interface ProjectScannerOptions {
sessionIndexPersistDelayMs?: number;
}
interface ScanInFlight {
generation: number;
promise: Promise<Project[]>;
}
function splitPathSegments(value: string): string[] {
return value.split(/[/\\]+/).filter(Boolean);
}
@ -184,6 +189,8 @@ export class ProjectScanner {
// Short-lived scan cache to prevent duplicate scans within the same request cycle.
// Both getProjects() and getRepositoryGroups() call scan() — the cache deduplicates.
private scanCache: { projects: Project[]; timestamp: number } | null = null;
private scanInFlight: ScanInFlight | null = null;
private scanGeneration = 0;
private static readonly SCAN_CACHE_TTL_MS = 2000;
/** Cached project list for search — avoids re-scanning disk on every query */
@ -243,6 +250,31 @@ export class ProjectScanner {
return this.scanCache.projects;
}
const generation = this.scanGeneration;
const inFlight = this.scanInFlight;
if (inFlight) {
if (inFlight.generation === generation) {
return inFlight.promise;
}
await inFlight.promise.catch(() => undefined);
if (this.scanInFlight?.promise === inFlight.promise) {
this.scanInFlight = null;
}
return this.scan();
}
const promise = this.performScan(generation);
this.scanInFlight = { generation, promise };
try {
return await promise;
} finally {
if (this.scanInFlight?.promise === promise) {
this.scanInFlight = null;
}
}
}
private async performScan(generation: number): Promise<Project[]> {
const startedAt = Date.now();
let stage = 'start';
const slowWarnAfterMs = 10_000;
@ -263,7 +295,7 @@ export class ProjectScanner {
stage = 'readdirProjectsDir';
const readdirStartedAt = Date.now();
const entries = await this.fsProvider.readdir(this.projectsDir);
const entries = await this.readdirForProjectDiscovery(this.projectsDir);
const readdirMs = Date.now() - readdirStartedAt;
if (readdirMs >= 2000) {
logger.warn(`[scan] readdir slow ms=${readdirMs} entries=${entries.length}`);
@ -298,7 +330,9 @@ export class ProjectScanner {
`[scan] completed slow ms=${ms} projectDirs=${projectDirs.length} projects=${validProjects.length}`
);
}
this.scanCache = { projects: validProjects, timestamp: Date.now() };
if (this.scanGeneration === generation) {
this.scanCache = { projects: validProjects, timestamp: Date.now() };
}
return validProjects;
} catch (error) {
logger.error('Error scanning projects directory:', error);
@ -314,6 +348,14 @@ export class ProjectScanner {
*/
clearScanCache(): void {
this.scanCache = null;
this.scanGeneration += 1;
}
private async readdirForProjectDiscovery(dirPath: string): Promise<FsDirent[]> {
if (this.fsProvider instanceof LocalFileSystemProvider) {
return this.fsProvider.readdir(dirPath, { prefetchEntryStats: false });
}
return this.fsProvider.readdir(dirPath);
}
// ===========================================================================
@ -459,7 +501,7 @@ export class ProjectScanner {
try {
const projectPath = path.join(this.projectsDir, encodedName);
const readdirStart = Date.now();
const entries = await this.fsProvider.readdir(projectPath);
const entries = await this.readdirForProjectDiscovery(projectPath);
const readdirMs = Date.now() - readdirStart;
// Get session files (.jsonl at root level)

View file

@ -23,6 +23,10 @@ const STAT_TIMEOUT_MS = 2000;
// let callers stat only the files they actually need.
const STAT_PREFETCH_LIMIT = 1500;
export interface LocalFileSystemProviderReaddirOptions {
prefetchEntryStats?: boolean;
}
async function statWithTimeout(filePath: string, timeoutMs: number): Promise<fs.Stats> {
let timer: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_resolve, reject) => {
@ -81,9 +85,12 @@ export class LocalFileSystemProvider implements FileSystemProvider {
};
}
async readdir(dirPath: string): Promise<FsDirent[]> {
async readdir(
dirPath: string,
options: LocalFileSystemProviderReaddirOptions = {}
): Promise<FsDirent[]> {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
if (entries.length > STAT_PREFETCH_LIMIT) {
if (options.prefetchEntryStats === false || entries.length > STAT_PREFETCH_LIMIT) {
return entries.map((entry) => ({
name: entry.name,
isFile: () => entry.isFile(),

View file

@ -44,7 +44,12 @@ interface RuntimeExtensionCapabilitiesResponse {
interface RuntimeProviderCapabilitiesResponse {
modelCatalog?: {
dynamic?: boolean;
source?: 'anthropic-models-api' | 'app-server' | 'static-fallback' | 'runtime';
source?:
| 'anthropic-models-api'
| 'anthropic-compatible-api'
| 'app-server'
| 'static-fallback'
| 'runtime';
};
reasoningEffort?: {
supported?: boolean;
@ -82,7 +87,7 @@ interface RuntimeProviderModelCatalogItemResponse {
supportsPersonality?: boolean;
isDefault?: boolean;
upgrade?: boolean;
source?: 'anthropic-models-api' | 'app-server' | 'static-fallback';
source?: 'anthropic-models-api' | 'anthropic-compatible-api' | 'app-server' | 'static-fallback';
badgeLabel?: string | null;
statusMessage?: string | null;
metadata?: Record<string, unknown> | null;
@ -91,7 +96,7 @@ interface RuntimeProviderModelCatalogItemResponse {
interface RuntimeProviderModelCatalogResponse {
schemaVersion?: number;
providerId?: CliProviderId;
source?: 'anthropic-models-api' | 'app-server' | 'static-fallback';
source?: 'anthropic-models-api' | 'anthropic-compatible-api' | 'app-server' | 'static-fallback';
status?: 'ready' | 'stale' | 'degraded' | 'unavailable';
fetchedAt?: string;
staleAt?: string;
@ -573,6 +578,7 @@ function mapRuntimeProviderModelCatalog(
!fetchedAt ||
!staleAt ||
(source !== 'anthropic-models-api' &&
source !== 'anthropic-compatible-api' &&
source !== 'app-server' &&
source !== 'static-fallback') ||
(status !== 'ready' && status !== 'stale' && status !== 'degraded' && status !== 'unavailable')
@ -597,6 +603,7 @@ function mapRuntimeProviderModelCatalog(
);
const itemSource =
model.source === 'anthropic-models-api' ||
model.source === 'anthropic-compatible-api' ||
model.source === 'app-server' ||
model.source === 'static-fallback'
? model.source

View file

@ -154,6 +154,28 @@ function buildAnthropicModelsUrl(baseUrl?: string | null): string {
return url.toString();
}
function isAnthropicCompatibleBaseUrl(baseUrl?: string | null): boolean {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return false;
}
try {
const host = new URL(trimmed).host;
return host !== 'api.anthropic.com' && host !== 'api-staging.anthropic.com';
} catch {
return true;
}
}
function hasAnthropicCompatibleAuthEnv(env: NodeJS.ProcessEnv): boolean {
if (!isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL)) {
return false;
}
return Boolean(env.ANTHROPIC_AUTH_TOKEN?.trim() || env.ANTHROPIC_API_KEY?.trim());
}
async function verifyAnthropicApiKeyWithApi(
apiKey: string,
baseUrl?: string | null
@ -376,6 +398,10 @@ export class ProviderConnectionService {
return null;
}
if (hasAnthropicCompatibleAuthEnv(env)) {
return null;
}
const storedKey = await this.apiKeyService.lookupPreferred('ANTHROPIC_API_KEY');
if (storedKey?.value.trim()) {
return storedKey.value.trim();
@ -392,6 +418,10 @@ export class ProviderConnectionService {
options?: StoredApiKeyAccessOptions
): Promise<NodeJS.ProcessEnv> {
if (providerId === 'anthropic') {
if (hasAnthropicCompatibleAuthEnv(env)) {
return env;
}
const authMode = this.getConfiguredAuthMode(providerId);
if (authMode === 'oauth') {
delete env.ANTHROPIC_API_KEY;
@ -562,6 +592,10 @@ export class ProviderConnectionService {
return null;
}
if (hasAnthropicCompatibleAuthEnv(env)) {
return null;
}
if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim()) {
return null;
}

View file

@ -76,6 +76,8 @@ const TEAM_RECURSIVE_SUBDIRS = ['.opencode-runtime', 'members'];
const ATOMIC_WRITE_TEMP_FILE_PREFIX = '.tmp.';
const FILE_LOCK_SUFFIX = '.lock';
const QUARANTINED_OPENCODE_LANE_INDEX_RE = /^lanes\.invalid\.\d+\.json$/;
const MEMBER_WORK_SYNC_DIR = '.member-work-sync';
const MEMBER_WORK_SYNC_JOURNAL_FILE = 'journal.jsonl';
// Subdirs under getAppDataPath() (our own storage, not in ~/.claude/)
const APP_DATA_SUBDIRS = ['attachments'];
const APP_DATA_DEEP_SUBDIRS = ['task-attachments'];
@ -122,6 +124,15 @@ function shouldCollectRecursiveBackupFile(relPath: string): boolean {
if (QUARANTINED_OPENCODE_LANE_INDEX_RE.test(fileName)) {
return false;
}
const segments = relPath.split('/');
const workSyncIndex = segments.lastIndexOf(MEMBER_WORK_SYNC_DIR);
if (
segments[0] === 'members' &&
workSyncIndex >= 2 &&
segments[workSyncIndex + 1] === MEMBER_WORK_SYNC_JOURNAL_FILE
) {
return false;
}
return true;
}
@ -140,12 +151,13 @@ async function collectRecursiveFiles(
continue;
}
if (entry.isFile()) {
if (!shouldCollectRecursiveBackupFile(relPath)) {
const descriptorRelPath = relPrefix ? `${relPrefix}/${relPath}` : relPath;
if (!shouldCollectRecursiveBackupFile(descriptorRelPath)) {
continue;
}
files.push({
sourcePath,
relPath: relPrefix ? `${relPrefix}/${relPath}` : relPath,
relPath: descriptorRelPath,
});
}
}
@ -167,12 +179,13 @@ function collectRecursiveFilesSync(rootDir: string, relPrefix: string): BackupFi
continue;
}
if (entry.isFile()) {
if (!shouldCollectRecursiveBackupFile(relPath)) {
const descriptorRelPath = relPrefix ? `${relPrefix}/${relPath}` : relPath;
if (!shouldCollectRecursiveBackupFile(descriptorRelPath)) {
continue;
}
files.push({
sourcePath,
relPath: relPrefix ? `${relPrefix}/${relPath}` : relPath,
relPath: descriptorRelPath,
});
}
}

View file

@ -507,6 +507,7 @@ function normalizeLaunchIdentity(
: 'default';
const catalogSource =
raw.catalogSource === 'anthropic-models-api' ||
raw.catalogSource === 'anthropic-compatible-api' ||
raw.catalogSource === 'app-server' ||
raw.catalogSource === 'static-fallback' ||
raw.catalogSource === 'runtime' ||

View file

@ -75,6 +75,7 @@ function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity |
const catalogSource =
raw.catalogSource === 'anthropic-models-api' ||
raw.catalogSource === 'anthropic-compatible-api' ||
raw.catalogSource === 'app-server' ||
raw.catalogSource === 'static-fallback' ||
raw.catalogSource === 'runtime' ||

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
export function getSystemLocale(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().locale;
} catch {
return process.env.LANG?.split('.')[0]?.replace('_', '-') ?? 'en';
}
}
export function getConfiguredAgentLanguageName(): string {
const config = ConfigManager.getInstance().getConfig();
const langCode = config.general.agentLanguage || 'system';
const systemLocale = getSystemLocale();
return resolveLanguageName(langCode, systemLocale);
}
export function getAgentLanguageInstruction(): string {
const languageName = getConfiguredAgentLanguageName();
return `IMPORTANT: Communicate in ${languageName}. All messages, summaries, and task descriptions MUST be in ${languageName}.`;
}

View file

@ -0,0 +1,264 @@
import * as agentTeamsControllerModule from 'agent-teams-controller';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getConfiguredAgentLanguageName } from './TeamProvisioningAgentLanguage';
import type { NativeAppManagedBootstrapSpec } from '../bootstrap/NativeAppManagedBootstrapContextBuilder';
import type {
EffortLevel,
TeamCreateRequest,
TeamLaunchRequest,
TeamProviderId,
} from '@shared/types';
const { AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES } = agentTeamsControllerModule;
const RUN_TIMEOUT_MS = 300_000;
export interface TeamProvisioningRunTimeoutInput {
deterministicBootstrap: boolean;
effectiveMembers: TeamCreateRequest['members'];
}
interface RuntimeBootstrapMemberSpec {
name: string;
prompt?: string;
cwd?: string;
model?: string;
provider?: TeamProviderId;
effort?: EffortLevel;
isolation?: 'worktree';
agentType?: string;
description?: string;
useSplitPane?: boolean;
planModeRequired?: boolean;
mcpConfigPath?: string;
mcpSettingSources?: string;
strictMcpConfig?: boolean;
nativeAppManagedBootstrap?: NativeAppManagedBootstrapSpec;
}
export interface RuntimeBootstrapMemberMcpLaunchConfig {
mcpConfigPath: string;
mcpSettingSources: string;
strictMcpConfig: boolean;
}
export interface RuntimeBootstrapSpec {
version: 1;
runId: string;
mode: 'create' | 'launch';
initiator: {
kind: 'app';
source: 'claude_team_agent_teams_orchestrator';
};
team: {
name: string;
displayName?: string;
description?: string;
color?: string;
cwd: string;
};
lead: {
agentLanguage?: string;
permissionSeedTools?: string[];
};
members: RuntimeBootstrapMemberSpec[];
launch?: {
bootstrapTimeoutMs?: number;
continueOnPartialFailure?: boolean;
};
ui?: {
emitStructuredEvents?: boolean;
};
}
const DETERMINISTIC_BOOTSTRAP_MIN_TIMEOUT_MS = 120_000;
const DETERMINISTIC_BOOTSTRAP_TIMEOUT_PER_MEMBER_MS = 75_000;
const DETERMINISTIC_BOOTSTRAP_MAX_TIMEOUT_MS = 900_000;
const DETERMINISTIC_BOOTSTRAP_OUTER_TIMEOUT_GRACE_MS = 30_000;
export function getDeterministicBootstrapTimeoutMs(memberCount: number): number {
const perMemberBudget = Math.max(0, memberCount) * DETERMINISTIC_BOOTSTRAP_TIMEOUT_PER_MEMBER_MS;
return Math.min(
DETERMINISTIC_BOOTSTRAP_MAX_TIMEOUT_MS,
Math.max(DETERMINISTIC_BOOTSTRAP_MIN_TIMEOUT_MS, perMemberBudget)
);
}
export function getProvisioningRunTimeoutMs(run: TeamProvisioningRunTimeoutInput): number {
if (!run.deterministicBootstrap) {
return RUN_TIMEOUT_MS;
}
return Math.max(
RUN_TIMEOUT_MS,
getDeterministicBootstrapTimeoutMs(run.effectiveMembers.length) +
DETERMINISTIC_BOOTSTRAP_OUTER_TIMEOUT_GRACE_MS
);
}
export function buildDeterministicCreateBootstrapSpec(
runId: string,
request: TeamCreateRequest,
effectiveMembers: TeamCreateRequest['members'],
nativeAppManagedBootstrapByMember: ReadonlyMap<string, NativeAppManagedBootstrapSpec> = new Map(),
mcpLaunchConfigByMember: ReadonlyMap<string, RuntimeBootstrapMemberMcpLaunchConfig> = new Map()
): RuntimeBootstrapSpec {
return {
version: 1,
runId,
mode: 'create',
initiator: {
kind: 'app',
source: 'claude_team_agent_teams_orchestrator',
},
team: {
name: request.teamName,
...(request.displayName?.trim() ? { displayName: request.displayName.trim() } : {}),
...(request.description?.trim() ? { description: request.description.trim() } : {}),
...(request.color?.trim() ? { color: request.color.trim() } : {}),
cwd: request.cwd,
},
lead: {
agentLanguage: getConfiguredAgentLanguageName(),
...(request.skipPermissions === false
? {
permissionSeedTools: [
...AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES,
'Edit',
'Write',
'NotebookEdit',
],
}
: {}),
},
members: effectiveMembers.map((member) => {
const mcpLaunchConfig = mcpLaunchConfigByMember.get(member.name);
return {
name: member.name,
...(member.role?.trim() ? { role: member.role.trim() } : {}),
...(member.workflow?.trim() ? { workflow: member.workflow.trim() } : {}),
...(request.cwd ? { cwd: request.cwd } : {}),
...(member.model?.trim() ? { model: member.model.trim() } : {}),
...(member.providerId ? { provider: member.providerId } : {}),
...(member.effort ? { effort: member.effort } : {}),
...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
...(member.role?.trim() ? { description: member.role.trim() } : {}),
...(mcpLaunchConfig ? mcpLaunchConfig : {}),
...(nativeAppManagedBootstrapByMember.get(member.name)
? { nativeAppManagedBootstrap: nativeAppManagedBootstrapByMember.get(member.name)! }
: {}),
};
}),
launch: {
bootstrapTimeoutMs: getDeterministicBootstrapTimeoutMs(effectiveMembers.length),
continueOnPartialFailure: true,
},
ui: {
emitStructuredEvents: true,
},
};
}
export function buildDeterministicLaunchBootstrapSpec(
runId: string,
request: TeamLaunchRequest,
effectiveMembers: TeamCreateRequest['members'],
nativeAppManagedBootstrapByMember: ReadonlyMap<string, NativeAppManagedBootstrapSpec> = new Map(),
mcpLaunchConfigByMember: ReadonlyMap<string, RuntimeBootstrapMemberMcpLaunchConfig> = new Map()
): RuntimeBootstrapSpec {
return {
version: 1,
runId,
mode: 'launch',
initiator: {
kind: 'app',
source: 'claude_team_agent_teams_orchestrator',
},
team: {
name: request.teamName,
cwd: request.cwd,
},
lead: {
agentLanguage: getConfiguredAgentLanguageName(),
...(request.skipPermissions === false
? {
permissionSeedTools: [
...AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES,
'Edit',
'Write',
'NotebookEdit',
],
}
: {}),
},
members: effectiveMembers.map((member) => {
const mcpLaunchConfig = mcpLaunchConfigByMember.get(member.name);
return {
name: member.name,
...(request.cwd ? { cwd: request.cwd } : {}),
...(member.model?.trim() ? { model: member.model.trim() } : {}),
...(member.providerId ? { provider: member.providerId } : {}),
...(member.effort ? { effort: member.effort } : {}),
...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
...(member.role?.trim() ? { role: member.role.trim() } : {}),
...(member.workflow?.trim() ? { workflow: member.workflow.trim() } : {}),
...(member.role?.trim() ? { description: member.role.trim() } : {}),
...(mcpLaunchConfig ? mcpLaunchConfig : {}),
...(nativeAppManagedBootstrapByMember.get(member.name)
? { nativeAppManagedBootstrap: nativeAppManagedBootstrapByMember.get(member.name)! }
: {}),
};
}),
launch: {
bootstrapTimeoutMs: getDeterministicBootstrapTimeoutMs(effectiveMembers.length),
continueOnPartialFailure: true,
},
ui: {
emitStructuredEvents: true,
},
};
}
export async function writeDeterministicBootstrapSpecFile(
spec: RuntimeBootstrapSpec
): Promise<string> {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-teams-bootstrap-'));
const filePath = path.join(tempDir, `${spec.team.name}-${randomUUID()}.json`);
await fs.promises.writeFile(filePath, JSON.stringify(spec), {
encoding: 'utf8',
mode: 0o600,
});
return filePath;
}
async function removeDeterministicBootstrapTempFile(filePath: string | null): Promise<void> {
if (!filePath) return;
await fs.promises.rm(filePath, { force: true }).catch(() => {});
await fs.promises.rmdir(path.dirname(filePath)).catch(() => {});
}
export async function removeDeterministicBootstrapSpecFile(filePath: string | null): Promise<void> {
await removeDeterministicBootstrapTempFile(filePath);
}
export async function writeDeterministicBootstrapUserPromptFile(prompt: string): Promise<string> {
const tempDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'agent-teams-bootstrap-prompt-')
);
const filePath = path.join(tempDir, `${randomUUID()}.txt`);
await fs.promises.writeFile(filePath, prompt, {
encoding: 'utf8',
mode: 0o600,
});
return filePath;
}
export async function removeDeterministicBootstrapUserPromptFile(
filePath: string | null
): Promise<void> {
await removeDeterministicBootstrapTempFile(filePath);
}

View file

@ -0,0 +1,77 @@
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import type { TeamCreateRequest, TeamProviderId } from '@shared/types';
export function getExplicitLaunchModelSelection(model: string | undefined): string | undefined {
const trimmed = model?.trim();
if (!trimmed || isDefaultProviderModelSelection(trimmed)) {
return undefined;
}
return trimmed;
}
export type TeamMemberInput = TeamCreateRequest['members'][number];
export function normalizeTeamMemberProviderId(providerId: unknown): TeamProviderId | undefined {
return normalizeOptionalTeamProviderId(providerId);
}
export function normalizeTeamProviderLike(providerId: unknown): TeamProviderId | undefined {
return normalizeOptionalTeamProviderId(
typeof providerId === 'string' ? providerId.trim().toLowerCase() : providerId
);
}
export function teamRequestIncludesCodexMember(
request: Pick<TeamCreateRequest, 'providerId'> & Partial<Pick<TeamCreateRequest, 'members'>>
): boolean {
const defaultProviderId = normalizeTeamMemberProviderId(request.providerId) ?? 'anthropic';
const members = Array.isArray(request.members) ? request.members : [];
return members.some((member) => {
const memberProviderId =
normalizeTeamMemberProviderId(member.providerId) ??
normalizeTeamMemberProviderId((member as { provider?: unknown }).provider) ??
defaultProviderId;
return memberProviderId === 'codex';
});
}
export function buildEffectiveTeamMemberSpec(
member: TeamMemberInput,
defaults: {
providerId?: TeamProviderId;
model?: string;
effort?: TeamCreateRequest['effort'];
}
): TeamMemberInput {
const memberProviderId = normalizeTeamMemberProviderId(member.providerId);
const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId);
const effectiveProviderId = memberProviderId ?? defaultProviderId ?? 'anthropic';
const explicitMemberModel = getExplicitLaunchModelSelection(member.model);
const inheritsDefaultRuntime = memberProviderId == null || memberProviderId === defaultProviderId;
const model =
explicitMemberModel ||
(inheritsDefaultRuntime ? getExplicitLaunchModelSelection(defaults.model) : undefined) ||
undefined;
const effort =
member.effort ?? (inheritsDefaultRuntime && !explicitMemberModel ? defaults.effort : undefined);
return {
...member,
providerId: effectiveProviderId,
model,
effort,
};
}
export function buildEffectiveTeamMemberSpecs(
members: TeamCreateRequest['members'],
defaults: {
providerId?: TeamProviderId;
model?: string;
effort?: TeamCreateRequest['effort'];
}
): TeamCreateRequest['members'] {
return members.map((member) => buildEffectiveTeamMemberSpec(member, defaults));
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,18 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="163" height="26" viewBox="0 0 163 26" fill="none">
<rect width="163" height="26" rx="4" fill="#ffffff"/>
<g>
<path d="M13.4447 1.71661e-05L0 25.9887C6.22692 23.5227 11.249 23.1623 15.7643 23.3763L13.7037 18.8159C12.8029 18.7258 10.1905 18.7258 8.9519 19.0523L13.4447 9.06451C13.4447 9.06451 20.2009 23.7366 20.2121 23.7366C21.5183 23.9393 24.9977 25.1104 26.8895 25.9887L13.4447 1.71661e-05Z" fill="#111827"/>
</g>
<g>
<path d="M32.9475 25.7973C30.9995 25.7973 29.4793 25.2568 28.3984 24.1871C27.3174 23.1174 26.7769 21.6085 26.7769 19.6492V12.0148H23.8154V8.28766H24.1307C24.9752 8.28766 25.6283 8.06245 26.09 7.6233C26.5404 7.17289 26.7769 6.53106 26.7769 5.68654V4.34657H30.9769V8.28766H34.9518V12.0148H30.9769V19.4353C30.9769 20.0096 31.0783 20.4938 31.281 20.8991C31.4837 21.3045 31.7989 21.6085 32.2381 21.8225C32.6772 22.0364 33.229 22.1378 33.9046 22.1378C34.051 22.1378 34.2312 22.1378 34.4338 22.104C34.6365 22.0814 34.828 22.0589 35.0194 22.0364V25.6059C34.7266 25.6509 34.3775 25.696 34.006 25.7298C33.6231 25.7748 33.274 25.7973 32.9588 25.7973H32.9475Z" fill="#111827"/>
<path d="M36.5732 25.6059V1.52028H40.7733V25.6059H36.5732Z" fill="#111827"/>
<path d="M48.2839 25.9888C47.079 25.9888 46.0206 25.7861 45.1197 25.3807C44.2189 24.9753 43.5208 24.4011 43.0366 23.6466C42.5524 22.8922 42.3047 22.0139 42.3047 21.023C42.3047 20.0321 42.5186 19.2101 42.9578 18.4557C43.3969 17.7012 44.05 17.0706 44.9508 16.5639C45.8404 16.0572 46.9664 15.6969 48.3289 15.483L53.959 14.5596V17.7463L49.1171 18.602C48.2951 18.7484 47.6758 19.0074 47.2705 19.379C46.8651 19.7506 46.6624 20.246 46.6624 20.8541C46.6624 21.4621 46.8876 21.9238 47.3493 22.2729C47.7997 22.6219 48.374 22.8021 49.0496 22.8021C49.9166 22.8021 50.6936 22.6219 51.3579 22.2504C52.0223 21.8788 52.5403 21.3608 52.9006 20.7077C53.2609 20.0546 53.4411 19.334 53.4411 18.5795V14.0867C53.4411 13.3435 53.1596 12.7242 52.5853 12.2287C52.011 11.7333 51.2453 11.4856 50.2995 11.4856C49.4099 11.4856 48.6217 11.7333 47.9236 12.2175C47.2367 12.7017 46.73 13.3322 46.4147 14.0979L43.0141 12.4427C43.3519 11.5306 43.8924 10.7424 44.6243 10.0668C45.3562 9.39116 46.2233 8.87319 47.2142 8.49034C48.2163 8.10749 49.2973 7.91607 50.4571 7.91607C51.8759 7.91607 53.1258 8.17505 54.2068 8.69303C55.2878 9.211 56.1323 9.94291 56.7403 10.8775C57.3484 11.8121 57.6524 12.8818 57.6524 14.0867V25.6059H53.7113V22.6445L54.6009 22.6107C54.1505 23.3313 53.6212 23.9507 52.9907 24.4574C52.3601 24.9641 51.662 25.3469 50.885 25.6059C50.108 25.8649 49.241 25.9888 48.2951 25.9888H48.2839Z" fill="#111827"/>
<path d="M66.4802 25.9888C64.6336 25.9888 63.0233 25.5496 61.6609 24.6713C60.2871 23.793 59.3412 22.5994 58.812 21.0906L61.9649 19.5929C62.4153 20.5726 63.0346 21.3383 63.8228 21.89C64.6223 22.4418 65.5006 22.712 66.4802 22.712C67.2234 22.712 67.8202 22.5431 68.2594 22.2053C68.7098 21.8675 68.9237 21.4171 68.9237 20.8653C68.9237 20.5275 68.8336 20.246 68.6535 20.0208C68.4733 19.7956 68.2368 19.6042 67.9328 19.4466C67.64 19.2889 67.291 19.1538 66.9194 19.0524L64.0818 18.253C62.6405 17.8476 61.537 17.2058 60.7826 16.3275C60.0281 15.4492 59.6565 14.4132 59.6565 13.2196C59.6565 12.1612 59.9268 11.2266 60.4673 10.4384C61.0078 9.65015 61.7622 9.01957 62.7306 8.58042C63.699 8.13001 64.8025 7.91607 66.0524 7.91607C67.6851 7.91607 69.1264 8.31018 70.3763 9.09839C71.6262 9.88661 72.5157 10.9901 73.045 12.4089L69.8583 13.9065C69.5656 13.1183 69.0588 12.499 68.3607 12.0486C67.6626 11.5982 66.8743 11.3617 66.0073 11.3617C65.3092 11.3617 64.7574 11.5193 64.3521 11.8234C63.9467 12.1274 63.744 12.5553 63.744 13.0845C63.744 13.3773 63.8341 13.6475 64.003 13.884C64.1719 14.1205 64.4084 14.3119 64.7236 14.4583C65.0277 14.6047 65.388 14.7398 65.7934 14.8749L68.5634 15.6969C69.9822 16.1248 71.0857 16.7554 71.8626 17.6111C72.6396 18.4557 73.0224 19.5029 73.0224 20.7302C73.0224 21.7662 72.7409 22.6895 72.2005 23.4777C71.6487 24.2772 70.883 24.8965 69.9146 25.3357C68.935 25.7861 67.7977 26 66.4802 26V25.9888Z" fill="#111827"/>
<path d="M90.2282 25.9889C88.5279 25.9889 86.9627 25.6849 85.5214 25.0656C84.0801 24.4463 82.8302 23.5905 81.7717 22.487C80.7133 21.3835 79.88 20.0886 79.2719 18.6022C78.6639 17.1159 78.3599 15.4944 78.3599 13.7378C78.3599 11.9812 78.6526 10.3485 79.2494 8.85085C79.8462 7.35324 80.6795 6.05831 81.7492 4.96606C82.8189 3.87382 84.0688 3.0293 85.4989 2.42125C86.9289 1.8132 88.5053 1.50917 90.2282 1.50917C91.951 1.50917 93.4486 1.79068 94.7998 2.36495C96.1511 2.93922 97.2884 3.69366 98.223 4.63952C99.1576 5.58538 99.8219 6.62132 100.227 7.74735L96.3425 9.59403C95.8921 8.38918 95.1489 7.39828 94.0792 6.62132C93.0207 5.84436 91.737 5.46152 90.2282 5.46152C88.7193 5.46152 87.4356 5.81058 86.2983 6.50872C85.1611 7.20685 84.2828 8.17523 83.6522 9.4026C83.0216 10.63 82.7176 12.0713 82.7176 13.7265C82.7176 15.3818 83.0329 16.8344 83.6522 18.073C84.2828 19.3116 85.1611 20.28 86.2983 20.9894C87.4356 21.6875 88.7418 22.0366 90.2282 22.0366C91.7145 22.0366 93.0207 21.6537 94.0792 20.8768C95.1376 20.0998 95.8921 19.1202 96.3425 17.9379L100.227 19.7508C99.8219 20.8768 99.1576 21.9127 98.223 22.8586C97.2884 23.8045 96.1511 24.5589 94.7998 25.1332C93.4486 25.7074 91.9285 25.9889 90.2282 25.9889Z" fill="#111827"/>
<path d="M101.748 25.6059V1.52028H105.948V25.6059H101.748Z" fill="#111827"/>
<path d="M116.645 25.9884C114.968 25.9884 113.436 25.5943 112.051 24.8061C110.666 24.0178 109.551 22.9481 108.729 21.5969C107.907 20.2344 107.491 18.6917 107.491 16.9464C107.491 15.2011 107.907 13.6584 108.729 12.2959C109.551 10.9334 110.655 9.86371 112.04 9.08676C113.414 8.29854 114.956 7.90443 116.657 7.90443C118.357 7.90443 119.922 8.29854 121.307 9.08676C122.681 9.87497 123.784 10.9334 124.606 12.2847C125.417 13.6359 125.834 15.1898 125.834 16.9464C125.834 18.703 125.417 20.2344 124.595 21.5969C123.773 22.9594 122.67 24.0291 121.285 24.8061C119.911 25.5943 118.368 25.9884 116.668 25.9884H116.645ZM116.645 22.1712C117.602 22.1712 118.436 21.946 119.145 21.5068C119.854 21.0564 120.417 20.4371 120.834 19.6489C121.251 18.8494 121.453 17.9598 121.453 16.9577C121.453 15.9555 121.251 15.0659 120.834 14.289C120.417 13.5008 119.854 12.8927 119.145 12.4423C118.436 11.9919 117.602 11.778 116.645 11.778C115.688 11.778 114.889 12.0032 114.168 12.4423C113.447 12.8927 112.884 13.5008 112.468 14.289C112.051 15.0772 111.848 15.9668 111.848 16.9577C111.848 17.9486 112.051 18.8494 112.468 19.6489C112.884 20.4483 113.447 21.0677 114.168 21.5068C114.889 21.9572 115.722 22.1712 116.645 22.1712Z" fill="#111827"/>
<path d="M133.535 25.9884C132.172 25.9884 131.013 25.6956 130.033 25.0988C129.053 24.502 128.299 23.68 127.77 22.6215C127.24 21.5631 126.97 20.3245 126.97 18.8944V8.29852H131.17V18.5453C131.17 19.266 131.317 19.8966 131.598 20.4371C131.88 20.9775 132.296 21.4054 132.837 21.7095C133.377 22.0135 133.985 22.1711 134.672 22.1711C135.359 22.1711 135.956 22.0135 136.485 21.7095C137.014 21.4054 137.431 20.9775 137.724 20.4258C138.017 19.874 138.174 19.221 138.174 18.4553V8.29852H142.34V25.6055H138.399V22.2049L138.715 22.813C138.309 23.8714 137.656 24.6709 136.744 25.2001C135.832 25.7294 134.762 25.9996 133.535 25.9996V25.9884Z" fill="#111827"/>
<path d="M152.7 25.9888C151.022 25.9888 149.525 25.5947 148.196 24.7952C146.867 23.9957 145.82 22.9147 145.066 21.5297C144.3 20.156 143.917 18.6246 143.917 16.9468C143.917 15.269 144.3 13.7264 145.077 12.3639C145.854 11.0014 146.901 9.92042 148.207 9.12094C149.525 8.31021 151.011 7.9161 152.666 7.9161C153.984 7.9161 155.155 8.17508 156.179 8.69305C157.204 9.21103 158.015 9.94294 158.612 10.8775L157.97 11.7333V1.52028H162.136V25.6059H158.195V22.2616L158.645 23.0836C158.049 24.0408 157.227 24.7614 156.168 25.2456C155.11 25.7298 153.95 25.9775 152.7 25.9775V25.9888ZM153.139 22.1716C154.074 22.1716 154.907 21.9464 155.639 21.5072C156.371 21.0568 156.945 20.4487 157.362 19.6605C157.778 18.8723 157.981 17.9715 157.981 16.9581C157.981 15.9447 157.778 15.0664 157.362 14.2894C156.945 13.5012 156.371 12.8931 155.639 12.4427C154.907 11.9923 154.074 11.7784 153.139 11.7784C152.205 11.7784 151.371 12.0036 150.628 12.4427C149.885 12.8931 149.311 13.5012 148.894 14.2894C148.477 15.0776 148.275 15.9672 148.275 16.9581C148.275 17.949 148.477 18.8836 148.894 19.6605C149.311 20.4487 149.885 21.0568 150.628 21.5072C151.371 21.9576 152.205 22.1716 153.139 22.1716Z" fill="#111827"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.2 KiB

View file

@ -14,6 +14,7 @@ import {
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
import { api, isElectronMode } from '@renderer/api';
import atlasCloudLogo from '@renderer/assets/atlascloud-logo.svg';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import {
@ -61,6 +62,8 @@ import {
ChevronRight,
ChevronUp,
Download,
ExternalLink,
Handshake,
HelpCircle,
Loader2,
LogIn,
@ -72,8 +75,7 @@ import {
} from 'lucide-react';
import {
getAnthropicDashboardRateLimits,
getCodexDashboardRateLimits,
getDashboardRateLimitsForProvider,
isDashboardRateLimitSubscriptionMode,
shouldShowDashboardRateLimitSkeleton,
} from './providerDashboardRateLimits';
@ -104,6 +106,11 @@ const VARIANT_STYLES: Record<BannerVariant, { border: string; bg: string }> = {
/** Minimum banner height — prevents layout shift between states (loading → installed → checking). */
const BANNER_MIN_H = 'min-h-[4.25rem]';
const ANTHROPIC_LIMIT_REFRESH_INTERVAL_MS = 60 * 1000;
const SHOW_ATLAS_CLOUD_OPENCODE_BANNER = false;
const ATLAS_CLOUD_OPENCODE_PROVIDER_ID = 'atlascloud';
const ATLAS_CLOUD_CODING_PLAN_URL = 'https://www.atlascloud.ai/console/coding-plan';
const ATLAS_CLOUD_DESCRIPTION =
"Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out Atlas Cloud's new coding plan promotion for more budget-friendly API access.";
const DashboardRateLimitChips = ({
providerId,
@ -378,6 +385,7 @@ interface InstalledBannerProps {
onProviderLogin: (providerId: CliProviderId) => void;
onProviderLogout: (providerId: CliProviderId) => void;
onProviderManage: (providerId: CliProviderId) => void;
onOpenCodeProviderConnect: (providerId: string) => void;
onProviderRefresh: (providerId: CliProviderId) => void;
onCodexReconnect: () => void;
onCodexDeviceCodeLogin: () => void;
@ -675,6 +683,100 @@ function getOpenCodeDashboardChips(
];
}
const OpenCodeAtlasCloudBanner = ({
disabled,
onConnect,
}: {
disabled: boolean;
onConnect: () => void;
}): React.JSX.Element => (
<div
className="col-span-2 rounded-md border px-2.5 py-2"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.018)',
}}
>
<div className="flex flex-wrap items-center justify-between gap-2.5">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<img
src={atlasCloudLogo}
alt="Atlas Cloud"
className="h-4 w-auto shrink-0 rounded-[3px] opacity-75"
draggable={false}
/>
<span
className="min-w-0 truncate text-[11px] font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
Atlas Cloud coding plan
</span>
<span
className="rounded border px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text-muted)',
}}
>
Sponsor
</span>
<span
className="rounded border px-1.5 py-0.5 text-[9px] font-medium"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text-muted)',
}}
>
OpenCode provider
</span>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={onConnect}
disabled={disabled}
className="flex items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<LogIn className="size-3" />
Connect
</button>
<button
type="button"
onClick={() => void api.openExternal(ATLAS_CLOUD_CODING_PLAN_URL)}
className="flex items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-muted)',
}}
>
<ExternalLink className="size-3" />
Plan
</button>
<button
type="button"
disabled
className="flex cursor-not-allowed items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium disabled:opacity-50"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-muted)',
}}
title="Coming soon"
>
<Handshake className="size-3" />
Become a sponsor
</button>
</div>
</div>
<p className="mt-1.5 text-[10.5px] leading-4" style={{ color: 'var(--color-text-muted)' }}>
{ATLAS_CLOUD_DESCRIPTION}
</p>
</div>
);
const InstalledBanner = ({
cliStatus,
sourceProviderMap,
@ -699,6 +801,7 @@ const InstalledBanner = ({
onProviderLogin,
onProviderLogout,
onProviderManage,
onOpenCodeProviderConnect,
onProviderRefresh,
onCodexReconnect,
onCodexDeviceCodeLogin,
@ -832,9 +935,7 @@ const InstalledBanner = ({
: getProviderRuntimeBackendSummary(provider);
const connectionModeSummary = getProviderConnectionModeSummary(provider);
const credentialSummary = getProviderCredentialSummary(provider);
const codexDashboardRateLimits = getCodexDashboardRateLimits(provider);
const anthropicDashboardRateLimits = getAnthropicDashboardRateLimits(provider);
const dashboardRateLimits = codexDashboardRateLimits ?? anthropicDashboardRateLimits;
const dashboardRateLimits = getDashboardRateLimitsForProvider(provider);
const hasDashboardRateLimits = Boolean(dashboardRateLimits?.length);
const isSubscriptionRateLimitMode = isDashboardRateLimitSubscriptionMode({
provider,
@ -865,17 +966,18 @@ const InstalledBanner = ({
const anthropicRateLimitsLoading =
provider.providerId === 'anthropic' &&
(anthropicRateLimitsRefreshing || provider.modelCatalogRefreshState === 'loading');
const showRateLimitSkeleton =
(showSkeleton &&
shouldShowDashboardRateLimitSkeleton({
provider,
sourceProvider,
configuredAuthModes: providerConnectionAuthModes,
})) ||
(isSubscriptionRateLimitMode &&
!hasDashboardRateLimits &&
((provider.providerId === 'codex' && codexRateLimitsLoading) ||
anthropicRateLimitsLoading));
const rateLimitsLoading =
showSkeleton ||
(provider.providerId === 'codex' && codexRateLimitsLoading) ||
anthropicRateLimitsLoading ||
isSubscriptionRateLimitMode;
const showRateLimitSkeleton = shouldShowDashboardRateLimitSkeleton({
provider,
sourceProvider,
configuredAuthModes: providerConnectionAuthModes,
hasRateLimits: hasDashboardRateLimits,
loading: rateLimitsLoading,
});
const statusText = showSkeleton ? 'Checking...' : formatProviderStatusText(provider);
const modelCatalogLoading =
provider.modelCatalogRefreshState === 'loading' ||
@ -1150,6 +1252,14 @@ const InstalledBanner = ({
/>
</div>
)}
{!showSkeleton &&
SHOW_ATLAS_CLOUD_OPENCODE_BANNER &&
provider.providerId === 'opencode' ? (
<OpenCodeAtlasCloudBanner
disabled={actionDisabled}
onConnect={() => onOpenCodeProviderConnect(ATLAS_CLOUD_OPENCODE_PROVIDER_ID)}
/>
) : null}
{!showSkeleton && dashboardRateLimits && dashboardRateLimits.length > 0 && (
<div className="col-span-2">
<DashboardRateLimitChips
@ -1216,6 +1326,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
action: 'login' | 'logout';
} | null>(null);
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('anthropic');
const [manageRuntimeProviderId, setManageRuntimeProviderId] = useState<string | null>(null);
const [manageDialogOpen, setManageDialogOpen] = useState(false);
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
@ -1451,9 +1562,23 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
const handleProviderManage = useCallback((providerId: CliProviderId) => {
setManageProviderId(providerId);
setManageRuntimeProviderId(null);
setManageDialogOpen(true);
}, []);
const handleOpenCodeProviderConnect = useCallback((providerId: string) => {
setManageProviderId('opencode');
setManageRuntimeProviderId(providerId);
setManageDialogOpen(true);
}, []);
const handleManageDialogOpenChange = useCallback((open: boolean) => {
setManageDialogOpen(open);
if (!open) {
setManageRuntimeProviderId(null);
}
}, []);
const handleProviderRefresh = useCallback(
(providerId: CliProviderId) => {
void (async () => {
@ -1535,7 +1660,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
<>
<ProviderRuntimeSettingsDialog
open={manageDialogOpen}
onOpenChange={setManageDialogOpen}
onOpenChange={handleManageDialogOpenChange}
providers={visibleCliProviders}
projectPath={selectedProjectPath}
initialProviderId={
@ -1543,6 +1668,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
? manageProviderId
: (visibleCliProviders[0]?.providerId ?? 'anthropic')
}
initialRuntimeProviderId={manageRuntimeProviderId}
initialRuntimeProviderAction={manageRuntimeProviderId ? 'connect' : null}
providerStatusLoading={cliProviderStatusLoading}
disabled={isBusy || cliStatusLoading || !renderCliStatus.binaryPath}
codexRuntimeStatus={codexRuntimeStatus}
@ -1661,6 +1788,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
onProviderLogin={handleProviderLogin}
onProviderLogout={handleProviderLogout}
onProviderManage={handleProviderManage}
onOpenCodeProviderConnect={handleOpenCodeProviderConnect}
onProviderRefresh={handleProviderRefresh}
onCodexReconnect={handleCodexDashboardLogin}
onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin}
@ -1896,6 +2024,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
onProviderLogin={handleProviderLogin}
onProviderLogout={handleProviderLogout}
onProviderManage={handleProviderManage}
onOpenCodeProviderConnect={handleOpenCodeProviderConnect}
onProviderRefresh={handleProviderRefresh}
onCodexReconnect={handleCodexDashboardLogin}
onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin}
@ -1965,6 +2094,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
onProviderLogin={handleProviderLogin}
onProviderLogout={handleProviderLogout}
onProviderManage={handleProviderManage}
onOpenCodeProviderConnect={handleOpenCodeProviderConnect}
onProviderRefresh={handleProviderRefresh}
onCodexReconnect={handleCodexDashboardLogin}
onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin}
@ -2194,6 +2324,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
onProviderLogin={handleProviderLogin}
onProviderLogout={handleProviderLogout}
onProviderManage={handleProviderManage}
onOpenCodeProviderConnect={handleOpenCodeProviderConnect}
onProviderRefresh={handleProviderRefresh}
onCodexReconnect={handleCodexDashboardLogin}
onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin}

View file

@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest';
import {
getAnthropicDashboardRateLimits,
getCodexDashboardRateLimits,
getDashboardRateLimitsForProvider,
shouldShowDashboardRateLimitSkeleton,
} from './providerDashboardRateLimits';
@ -195,6 +196,31 @@ describe('providerDashboardRateLimits', () => {
]);
});
test('routes dashboard rate limits through the provider-specific formatter', () => {
const items = getDashboardRateLimitsForProvider(
createProvider({
authMethod: 'claude.ai',
subscriptionRateLimits: {
primary: {
usedPercent: 25,
windowDurationMins: 300,
resetsAt: null,
},
secondary: null,
},
})
);
expect(items).toEqual([
{
label: '5h left',
remaining: '75%',
resetsAt: 'reset unknown',
isDepleted: false,
},
]);
});
test('marks fully depleted limits when no quota remains', () => {
const connection = createCodexConnection();
@ -238,10 +264,45 @@ describe('providerDashboardRateLimits', () => {
configuredAuthModes: {
anthropic: 'oauth',
},
hasRateLimits: false,
loading: true,
})
).toBe(true);
});
test('keeps Anthropic rate limit skeletons visible while subscription limits are missing', () => {
expect(
shouldShowDashboardRateLimitSkeleton({
provider: createProvider({
authenticated: true,
authMethod: 'claude.ai',
subscriptionRateLimits: null,
}),
configuredAuthModes: {
anthropic: 'oauth',
},
hasRateLimits: false,
loading: true,
})
).toBe(true);
});
test('hides rate limit skeletons when formatted limit data is present', () => {
expect(
shouldShowDashboardRateLimitSkeleton({
provider: createProvider({
authenticated: true,
authMethod: 'claude.ai',
}),
configuredAuthModes: {
anthropic: 'oauth',
},
hasRateLimits: true,
loading: true,
})
).toBe(false);
});
test('hides Anthropic rate limit skeletons when API key mode is selected', () => {
expect(
shouldShowDashboardRateLimitSkeleton({
@ -258,6 +319,8 @@ describe('providerDashboardRateLimits', () => {
configuredAuthModes: {
anthropic: 'api_key',
},
hasRateLimits: false,
loading: true,
})
).toBe(false);
});
@ -276,6 +339,8 @@ describe('providerDashboardRateLimits', () => {
configuredAuthModes: {
codex: 'chatgpt',
},
hasRateLimits: false,
loading: true,
})
).toBe(true);
});
@ -300,6 +365,8 @@ describe('providerDashboardRateLimits', () => {
configuredAuthModes: {
codex: 'api_key',
},
hasRateLimits: false,
loading: true,
})
).toBe(false);
});

View file

@ -26,6 +26,11 @@ export interface DashboardRateLimitSkeletonModeInput {
};
}
export interface DashboardRateLimitSkeletonInput extends DashboardRateLimitSkeletonModeInput {
hasRateLimits: boolean;
loading: boolean;
}
function firstKnown<T>(...values: (T | null | undefined)[]): T | null {
for (const value of values) {
if (value !== null && typeof value !== 'undefined') {
@ -143,9 +148,9 @@ export function isDashboardRateLimitSubscriptionMode({
}
export function shouldShowDashboardRateLimitSkeleton(
input: DashboardRateLimitSkeletonModeInput
input: DashboardRateLimitSkeletonInput
): boolean {
return isDashboardRateLimitSubscriptionMode(input);
return input.loading && !input.hasRateLimits && isDashboardRateLimitSubscriptionMode(input);
}
function buildRateLimitLabel(
@ -272,3 +277,17 @@ export function getAnthropicDashboardRateLimits(
return items.length > 0 ? items : null;
}
export function getDashboardRateLimitsForProvider(
provider: CliProviderStatus
): DashboardRateLimitItem[] | null {
switch (provider.providerId) {
case 'codex':
return getCodexDashboardRateLimits(provider);
case 'anthropic':
return getAnthropicDashboardRateLimits(provider);
case 'gemini':
case 'opencode':
return null;
}
}

View file

@ -0,0 +1,95 @@
import React, { useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { ChevronDown, ChevronRight, ExternalLink, Info } from 'lucide-react';
const OPENCODE_CONFIG_DOCS_URL = 'https://opencode.ai/docs/config/';
const OPENCODE_PROVIDERS_DOCS_URL = 'https://opencode.ai/docs/providers';
const OPENCODE_CONTEXT_CONFIG_EXAMPLE = `{
"$schema": "https://opencode.ai/config.json",
"provider": {
"local": {
"models": {
"your-model": {
"limit": {
"context": 10000,
"output": 2000
}
}
}
}
},
"compaction": {
"auto": true,
"prune": true,
"reserved": 2000
}
}`;
export const OpenCodeContextConfigHint = (): React.JSX.Element => {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-md border border-cyan-500/20 bg-cyan-500/5 text-[11px] leading-relaxed text-cyan-100">
<Button
type="button"
variant="ghost"
size="sm"
className="flex h-auto w-full items-start justify-start gap-2 whitespace-normal rounded-md px-3 py-2 text-left text-[11px] font-normal text-cyan-100 hover:bg-cyan-500/10 hover:text-cyan-50"
onClick={() => setExpanded((current) => !current)}
aria-expanded={expanded}
>
{expanded ? (
<ChevronDown className="mt-0.5 size-3.5 shrink-0" />
) : (
<ChevronRight className="mt-0.5 size-3.5 shrink-0" />
)}
<Info className="mt-0.5 size-3.5 shrink-0 text-cyan-300" />
<span className="min-w-0">
OpenCode local models can use an OpenCode context budget instead of prompt-only limits.
</span>
</Button>
{expanded ? (
<div className="space-y-2 border-t border-cyan-500/15 px-3 pb-3 pt-2">
<p className="text-cyan-100/90">
Add matching limits to the OpenCode config for the provider and model used by this
teammate. This helps OpenCode compact and prune before local models overflow their
context window.
</p>
<pre className="max-h-72 overflow-auto rounded-md border border-cyan-500/20 bg-black/25 p-2 font-mono text-[10px] leading-relaxed text-cyan-50">
<code>{OPENCODE_CONTEXT_CONFIG_EXAMPLE}</code>
</pre>
<p className="text-cyan-100/80">
Replace <code className="font-mono">local</code> and{' '}
<code className="font-mono">your-model</code> with the provider and model IDs from your
OpenCode setup. Prompt instructions like{' '}
<code className="font-mono">stay below 10000 tokens</code> are weaker because the
request is assembled before the model reads them.
</p>
<div className="flex flex-wrap gap-3 text-cyan-100/90">
<a
href={OPENCODE_PROVIDERS_DOCS_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 underline underline-offset-2 hover:text-cyan-50"
>
Provider limits
<ExternalLink className="size-3" />
</a>
<a
href={OPENCODE_CONFIG_DOCS_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 underline underline-offset-2 hover:text-cyan-50"
>
Compaction config
<ExternalLink className="size-3" />
</a>
</div>
</div>
) : null}
</div>
);
};

View file

@ -87,6 +87,8 @@ interface OpenCodeModelGroup {
groupId: string;
groupLabel: string;
rank: number;
sortLabel: string;
firstIndex: number;
options: TeamRuntimeModelOption[];
}
@ -446,7 +448,7 @@ function getOpenCodeReadinessMessage(providerStatus: CliProviderStatus | null |
return 'The app is still checking the OpenCode runtime. Wait for provider status to finish, then try again.';
}
if (!providerStatus.supported) {
return 'OpenCode is not installed, not found, or the detected runtime is not supported. Install or update OpenCode, then refresh provider status.';
return 'OpenCode is not installed, not found, or the detected runtime is not supported. Install or update OpenCode, then refresh provider status. You can also use the Install button on the home page.';
}
if (!providerStatus.authenticated) {
if (hasFreeOpenCodeModelRoute(providerStatus)) {
@ -1150,21 +1152,32 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
continue;
}
const routeGroup = metadata.routeGroup;
const existingGroup = groups.get(routeGroup.id);
const sourceGroup = metadata.sourceInfo;
const groupId = sourceGroup ? `source:${sourceGroup.id}` : `route:${metadata.routeGroup.id}`;
const groupLabel = sourceGroup?.label ?? metadata.routeGroup.label;
const existingGroup = groups.get(groupId);
if (existingGroup) {
existingGroup.options.push(option);
existingGroup.rank = Math.min(existingGroup.rank, metadata.routeGroup.rank);
existingGroup.firstIndex = Math.min(existingGroup.firstIndex, metadata.index);
} else {
groups.set(routeGroup.id, {
groupId: routeGroup.id,
groupLabel: routeGroup.label,
rank: routeGroup.rank,
groups.set(groupId, {
groupId,
groupLabel,
rank: metadata.routeGroup.rank,
sortLabel: groupLabel.toLowerCase(),
firstIndex: metadata.index,
options: [option],
});
}
}
return Array.from(groups.values()).sort((left, right) => left.rank - right.rank);
return Array.from(groups.values()).sort(
(left, right) =>
left.rank - right.rank ||
left.sortLabel.localeCompare(right.sortLabel, undefined, { sensitivity: 'base' }) ||
left.firstIndex - right.firstIndex
);
}, [effectiveProviderId, visibleOpenCodeModelMetadata]);
const visibleDefaultModelOptions = visibleModelOptions.filter((option) => !option.value.trim());
const visibleConcreteModelOptionCount =
@ -1318,11 +1331,6 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
>
{opt.label}
</span>
{openCodeMetadata?.sourceInfo ? (
<span className="inline-flex items-center justify-center rounded-full border border-white/10 bg-white/[0.04] px-1.5 py-0 text-[9px] font-semibold uppercase text-[var(--color-text-secondary)]">
{openCodeMetadata.sourceInfo.label}
</span>
) : null}
{openCodePricingInfo?.summary ? (
<span
data-testid="team-model-selector-model-pricing"

View file

@ -22,6 +22,36 @@ vi.mock('@renderer/components/team/dialogs/LimitContextCheckbox', () => ({
),
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
className,
onClick,
disabled,
'aria-expanded': ariaExpanded,
'aria-label': ariaLabel,
}: {
children: React.ReactNode;
className?: string;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
'aria-expanded'?: boolean;
'aria-label'?: string;
}) =>
React.createElement(
'button',
{
className,
disabled,
onClick,
type: 'button',
'aria-expanded': ariaExpanded,
'aria-label': ariaLabel,
},
children
),
}));
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
formatTeamModelSummary: (providerId: string, model: string, effort?: string) =>
[providerId, model || 'Default', effort].filter(Boolean).join(' · '),
@ -250,4 +280,31 @@ describe('LeadModelRow', () => {
root.unmount();
});
});
it('shows the OpenCode context config hint inside OpenCode provider settings after effort', () => {
const { host, root } = renderLeadModelRow({
providerId: 'opencode',
model: 'local/model',
showAnthropicContextLimit: false,
});
const modelButton = host.querySelector<HTMLButtonElement>(
'button[aria-label="opencode provider, local/model"]'
)!;
act(() => {
modelButton.click();
});
const text = host.textContent ?? '';
const effortIndex = text.indexOf('effort-selector');
const hintIndex = text.indexOf('OpenCode local models can use an OpenCode context budget');
expect(hintIndex).toBeGreaterThan(-1);
expect(effortIndex).toBeGreaterThan(-1);
expect(hintIndex).toBeGreaterThan(effortIndex);
act(() => {
root.unmount();
});
});
});

View file

@ -8,6 +8,7 @@ import {
} from '@renderer/components/team/dialogs/AnthropicExtraUsageWarning';
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
import { OpenCodeContextConfigHint } from '@renderer/components/team/dialogs/OpenCodeContextConfigHint';
import {
getProviderScopedTeamModelLabel,
getTeamProviderLabel,
@ -229,6 +230,7 @@ export const LeadModelRow = ({
model={model}
limitContext={limitContext}
/>
{providerId === 'opencode' ? <OpenCodeContextConfigHint /> : null}
{showAnthropicContextLimit ? (
<LimitContextCheckbox
id="lead-limit-context"

View file

@ -418,6 +418,37 @@ describe('MemberDraftRow', () => {
});
});
it('shows the OpenCode context config hint inside OpenCode teammate provider settings after effort', () => {
const { host, root } = renderMemberDraftRow({
member: createMemberDraft({
id: 'member-1',
name: 'alice',
roleSelection: 'developer',
providerId: 'opencode',
model: 'local/model',
}),
});
const modelButton = host.querySelector<HTMLButtonElement>(
'button[aria-label="opencode provider, local/model"]'
)!;
act(() => {
modelButton.click();
});
const text = host.textContent ?? '';
const effortIndex = text.indexOf('effort-selector');
const hintIndex = text.indexOf('OpenCode local models can use an OpenCode context budget');
expect(hintIndex).toBeGreaterThan(-1);
expect(effortIndex).toBeGreaterThan(-1);
expect(hintIndex).toBeGreaterThan(effortIndex);
act(() => {
root.unmount();
});
});
it('shows model launch issues inline and keeps model controls expandable', () => {
const issueText =
'Member alice uses Anthropic effort "medium", but Haiku 4.5 does not support it in the current runtime.';

View file

@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { AnthropicExtraUsageWarning } from '@renderer/components/team/dialogs/AnthropicExtraUsageWarning';
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
import { OpenCodeContextConfigHint } from '@renderer/components/team/dialogs/OpenCodeContextConfigHint';
import {
formatTeamModelSummary,
getProviderScopedTeamModelLabel,
@ -874,6 +875,7 @@ export const MemberDraftRow = ({
model={effectiveModel}
limitContext={limitContext}
/>
{effectiveProviderId === 'opencode' ? <OpenCodeContextConfigHint /> : null}
{effectiveProviderId === 'anthropic' ? (
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />

View file

@ -128,6 +128,7 @@ export type CliProviderReasoningEffort =
export type CliProviderModelCatalogSource =
| 'anthropic-models-api'
| 'anthropic-compatible-api'
| 'app-server'
| 'static-fallback';
export type CliProviderModelCatalogStatus = 'ready' | 'stale' | 'degraded' | 'unavailable';

View file

@ -985,6 +985,7 @@ export interface ProviderModelLaunchIdentity {
catalogId: string | null;
catalogSource:
| 'anthropic-models-api'
| 'anthropic-compatible-api'
| 'app-server'
| 'static-fallback'
| 'runtime'

View file

@ -19,6 +19,11 @@ function isAnthropicSonnetModel(model: string): boolean {
return baseModel === 'sonnet' || baseModel.startsWith('claude-sonnet-');
}
function isAnthropicOpusModel(model: string): boolean {
const baseModel = stripOneMillionSuffix(model);
return baseModel === 'opus' || baseModel.startsWith('claude-opus-');
}
function getStandardContextAlias(model: string): string | null {
const baseModel = stripOneMillionSuffix(model);
if (baseModel === 'opus' || baseModel.startsWith('claude-opus-')) {
@ -125,6 +130,10 @@ export function resolveAnthropicLaunchModel(params: {
return baseModel;
}
if (!isAnthropicOpusModel(baseModel)) {
return selectedOneMillionContext ? `${baseModel}[1m]` : baseModel;
}
const preferredLongContextModel = `${baseModel}[1m]`;
if (availableModels.size === 0) {

View file

@ -0,0 +1,174 @@
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { ProjectScanner } from '../../../../src/main/services/discovery/ProjectScanner';
import { subprojectRegistry } from '../../../../src/main/services/discovery/SubprojectRegistry';
import type {
FileSystemProvider,
FsDirent,
FsStatResult,
ReadStreamOptions,
} from '../../../../src/main/services/infrastructure/FileSystemProvider';
interface CountingProvider extends FileSystemProvider {
readonly readdirCounts: Map<string, number>;
getMaxConcurrentReaddirs(): number;
releaseBlockedRead(): void;
waitForBlockedRead(): Promise<void>;
}
function createSessionLine(cwd: string): string {
return JSON.stringify({
uuid: crypto.randomUUID(),
type: 'user',
cwd,
timestamp: '2026-01-01T00:00:00.000Z',
message: { role: 'user', content: 'hello' },
});
}
function createProject(projectsDir: string, encodedName: string, cwd: string): string {
const projectDir = path.join(projectsDir, encodedName);
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(path.join(projectDir, 'session-1.jsonl'), `${createSessionLine(cwd)}\n`);
return projectDir;
}
function toStatResult(stats: fs.Stats): FsStatResult {
return {
size: stats.size,
mtimeMs: stats.mtimeMs,
birthtimeMs: stats.birthtimeMs,
isFile: () => stats.isFile(),
isDirectory: () => stats.isDirectory(),
};
}
function toDirent(entry: fs.Dirent): FsDirent {
return {
name: entry.name,
isFile: () => entry.isFile(),
isDirectory: () => entry.isDirectory(),
};
}
function createCountingProvider(blockFirstReadPath?: string): CountingProvider {
const readdirCounts = new Map<string, number>();
let blockedReadStartedResolve: (() => void) | null = null;
let releaseBlockedReadResolve: (() => void) | null = null;
let activeReaddirs = 0;
let maxConcurrentReaddirs = 0;
const blockedReadStarted = blockFirstReadPath
? new Promise<void>((resolve) => {
blockedReadStartedResolve = resolve;
})
: Promise.resolve();
return {
type: 'local',
readdirCounts,
async exists(filePath: string): Promise<boolean> {
return fs.existsSync(filePath);
},
async readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise<string> {
return fs.promises.readFile(filePath, encoding);
},
async stat(filePath: string): Promise<FsStatResult> {
return toStatResult(await fs.promises.stat(filePath));
},
async readdir(dirPath: string): Promise<FsDirent[]> {
activeReaddirs += 1;
maxConcurrentReaddirs = Math.max(maxConcurrentReaddirs, activeReaddirs);
try {
const count = (readdirCounts.get(dirPath) ?? 0) + 1;
readdirCounts.set(dirPath, count);
if (dirPath === blockFirstReadPath && count === 1) {
blockedReadStartedResolve?.();
await new Promise<void>((resolve) => {
releaseBlockedReadResolve = resolve;
});
}
return (await fs.promises.readdir(dirPath, { withFileTypes: true })).map(toDirent);
} finally {
activeReaddirs -= 1;
}
},
createReadStream(filePath: string, opts?: ReadStreamOptions): fs.ReadStream {
return fs.createReadStream(filePath, {
start: opts?.start,
encoding: opts?.encoding,
});
},
dispose(): void {
releaseBlockedReadResolve?.();
},
getMaxConcurrentReaddirs(): number {
return maxConcurrentReaddirs;
},
releaseBlockedRead(): void {
releaseBlockedReadResolve?.();
},
waitForBlockedRead(): Promise<void> {
return blockedReadStarted;
},
};
}
describe('ProjectScanner scan dedup safe e2e', () => {
const tempDirs: string[] = [];
afterEach(() => {
subprojectRegistry.clear();
for (const dir of tempDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
tempDirs.length = 0;
});
it('shares one in-flight scan between project and repository-group startup reads', async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-dedup-'));
tempDirs.push(rootDir);
const projectsDir = path.join(rootDir, 'projects');
const encodedName = '-Users-test-dedup-project';
const projectDir = createProject(projectsDir, encodedName, '/Users/test/dedup-project');
const provider = createCountingProvider();
const scanner = new ProjectScanner(projectsDir, undefined, provider);
const [projects, groups] = await Promise.all([
scanner.scan(),
scanner.scanWithWorktreeGrouping(),
]);
expect(projects.map((project) => project.id)).toContain(encodedName);
expect(groups.map((group) => group.id)).toContain(encodedName);
expect(provider.readdirCounts.get(projectsDir)).toBe(1);
expect(provider.readdirCounts.get(projectDir)).toBe(1);
});
it('does not cache an in-flight scan after clearScanCache invalidates it', async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-dedup-clear-'));
tempDirs.push(rootDir);
const projectsDir = path.join(rootDir, 'projects');
createProject(projectsDir, '-Users-test-clear-project', '/Users/test/clear-project');
const provider = createCountingProvider(projectsDir);
const scanner = new ProjectScanner(projectsDir, undefined, provider);
const firstScan = scanner.scan();
await provider.waitForBlockedRead();
scanner.clearScanCache();
const secondScan = scanner.scan();
const thirdScan = scanner.scan();
provider.releaseBlockedRead();
await expect(firstScan).resolves.toHaveLength(1);
await expect(secondScan).resolves.toHaveLength(1);
await expect(thirdScan).resolves.toHaveLength(1);
expect(provider.readdirCounts.get(projectsDir)).toBe(2);
expect(provider.getMaxConcurrentReaddirs()).toBe(1);
});
});

View file

@ -0,0 +1,53 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { LocalFileSystemProvider } from '../../../../src/main/services/infrastructure/LocalFileSystemProvider';
describe('LocalFileSystemProvider', () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
tempDirs.length = 0;
});
function createFixture(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'local-fs-provider-'));
tempDirs.push(dir);
fs.writeFileSync(path.join(dir, 'session.jsonl'), '{}\n', 'utf8');
fs.mkdirSync(path.join(dir, 'nested'));
return dir;
}
it('can return bare dirents without eager stat metadata', async () => {
const dir = createFixture();
const provider = new LocalFileSystemProvider();
const entries = await provider.readdir(dir, { prefetchEntryStats: false });
const fileEntry = entries.find((entry) => entry.name === 'session.jsonl');
const dirEntry = entries.find((entry) => entry.name === 'nested');
expect(fileEntry?.isFile()).toBe(true);
expect(fileEntry?.size).toBeUndefined();
expect(fileEntry?.mtimeMs).toBeUndefined();
expect(dirEntry?.isDirectory()).toBe(true);
});
it('keeps eager stat metadata as the default behavior', async () => {
const dir = createFixture();
const provider = new LocalFileSystemProvider();
const entries = await provider.readdir(dir);
const fileEntry = entries.find((entry) => entry.name === 'session.jsonl');
expect(fileEntry?.isFile()).toBe(true);
expect(fileEntry?.size).toBe(Buffer.byteLength('{}\n'));
expect(typeof fileEntry?.mtimeMs).toBe('number');
expect(typeof fileEntry?.birthtimeMs).toBe('number');
});
});

View file

@ -38,6 +38,7 @@ describe('ProviderConnectionService', () => {
const originalOpenAiApiKey = process.env.OPENAI_API_KEY;
const originalCodexApiKey = process.env.CODEX_API_KEY;
const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY;
const originalAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN;
const originalAnthropicBaseUrl = process.env.ANTHROPIC_BASE_URL;
function createConfig(authMode: 'auto' | 'oauth' | 'api_key' = 'auto') {
@ -128,6 +129,7 @@ describe('ProviderConnectionService', () => {
delete process.env.OPENAI_API_KEY;
delete process.env.CODEX_API_KEY;
delete process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_AUTH_TOKEN;
delete process.env.ANTHROPIC_BASE_URL;
});
@ -150,6 +152,12 @@ describe('ProviderConnectionService', () => {
process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey;
}
if (originalAnthropicAuthToken === undefined) {
delete process.env.ANTHROPIC_AUTH_TOKEN;
} else {
process.env.ANTHROPIC_AUTH_TOKEN = originalAnthropicAuthToken;
}
if (originalAnthropicBaseUrl === undefined) {
delete process.env.ANTHROPIC_BASE_URL;
} else {
@ -182,6 +190,33 @@ describe('ProviderConnectionService', () => {
expect(result.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
});
it('preserves Anthropic-compatible bearer token env even when OAuth mode is selected', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('oauth'),
} as never
);
const result = await service.applyConfiguredConnectionEnv(
{
ANTHROPIC_BASE_URL: 'http://localhost:11434',
ANTHROPIC_API_KEY: '',
ANTHROPIC_AUTH_TOKEN: 'ollama',
},
'anthropic'
);
expect(result.ANTHROPIC_BASE_URL).toBe('http://localhost:11434');
expect(result.ANTHROPIC_API_KEY).toBe('');
expect(result.ANTHROPIC_AUTH_TOKEN).toBe('ollama');
});
it('injects the stored Anthropic API key when api_key mode is selected', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'ANTHROPIC_API_KEY',
@ -212,6 +247,37 @@ describe('ProviderConnectionService', () => {
expect(result.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
});
it('does not replace Anthropic-compatible bearer token env with stored API key mode credentials', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'ANTHROPIC_API_KEY',
value: 'stored-key',
});
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred,
} as never,
{
getConfig: () => createConfig('api_key'),
} as never
);
const result = await service.applyConfiguredConnectionEnv(
{
ANTHROPIC_BASE_URL: 'http://localhost:11434',
ANTHROPIC_API_KEY: '',
ANTHROPIC_AUTH_TOKEN: 'ollama',
},
'anthropic'
);
expect(lookupPreferred).not.toHaveBeenCalled();
expect(result.ANTHROPIC_API_KEY).toBe('');
expect(result.ANTHROPIC_AUTH_TOKEN).toBe('ollama');
});
it('does not decrypt stored Anthropic keys when metadata-only env building is requested', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'ANTHROPIC_API_KEY',
@ -279,6 +345,31 @@ describe('ProviderConnectionService', () => {
expect(issue).toContain('ANTHROPIC_API_KEY');
});
it('does not report a missing Anthropic API key for Anthropic-compatible bearer token env', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('api_key'),
} as never
);
const issue = await service.getConfiguredConnectionIssue(
{
ANTHROPIC_BASE_URL: 'http://localhost:11434',
ANTHROPIC_API_KEY: '',
ANTHROPIC_AUTH_TOKEN: 'ollama',
},
'anthropic'
);
expect(issue).toBeNull();
});
it('treats a stored Anthropic API key as configured even when env is empty', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'ANTHROPIC_API_KEY',

View file

@ -1,7 +1,6 @@
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
@ -316,12 +315,13 @@ describe('TeamBackupService', () => {
}
});
it('backs up member-scoped work sync files', async () => {
it('backs up member-scoped work sync status without copying the append-only journal', async () => {
const service = new TeamBackupService();
const teamName = 'member-work-sync-team';
const teamDir = path.join(hoisted.teamsBase, teamName);
const memberDir = path.join(teamDir, 'members', 'jack');
const workSyncDir = path.join(memberDir, '.member-work-sync');
const runtimeWorkSyncDir = path.join(teamDir, '.opencode-runtime', '.member-work-sync');
const status = {
teamName,
memberName: 'jack',
@ -366,6 +366,23 @@ describe('TeamBackupService', () => {
);
await fs.writeFile(path.join(workSyncDir, '.tmp.deadbeef'), '{"partial":', 'utf8');
await fs.writeFile(path.join(workSyncDir, 'journal.jsonl.lock'), '123\n', 'utf8');
await fs.mkdir(runtimeWorkSyncDir, { recursive: true });
await fs.writeFile(
path.join(runtimeWorkSyncDir, 'journal.jsonl'),
'{"runtime":true}\n',
'utf8'
);
const staleBackupJournalPath = path.join(
hoisted.backupsBase,
'teams',
teamName,
'members',
'jack',
'.member-work-sync',
'journal.jsonl'
);
await fs.mkdir(path.dirname(staleBackupJournalPath), { recursive: true });
await fs.writeFile(staleBackupJournalPath, '{"old":true}\n', 'utf8');
await service.initialize();
await service.backupTeam(teamName);
@ -383,14 +400,46 @@ describe('TeamBackupService', () => {
fs.readFile(path.join(backupMemberDir, '.member-work-sync', 'status.json'), 'utf8')
).resolves.toBe(JSON.stringify({ schemaVersion: 2, status }));
await expect(
fs.readFile(path.join(backupMemberDir, '.member-work-sync', 'journal.jsonl'), 'utf8')
).resolves.toContain('"event":"status_written"');
fs.stat(path.join(backupMemberDir, '.member-work-sync', 'journal.jsonl'))
).rejects.toMatchObject({ code: 'ENOENT' });
await expect(
fs.stat(path.join(backupMemberDir, '.member-work-sync', '.tmp.deadbeef'))
).rejects.toMatchObject({ code: 'ENOENT' });
await expect(
fs.stat(path.join(backupMemberDir, '.member-work-sync', 'journal.jsonl.lock'))
).rejects.toMatchObject({ code: 'ENOENT' });
await expect(
fs.readFile(
path.join(
hoisted.backupsBase,
'teams',
teamName,
'.opencode-runtime',
'.member-work-sync',
'journal.jsonl'
),
'utf8'
)
).resolves.toBe('{"runtime":true}\n');
const manifest = JSON.parse(
await fs.readFile(
path.join(hoisted.backupsBase, 'teams', teamName, 'manifest.json'),
'utf8'
)
) as { fileStats: Record<string, unknown> };
expect(
Object.prototype.hasOwnProperty.call(
manifest.fileStats,
'members/jack/.member-work-sync/status.json'
)
).toBe(true);
expect(
Object.prototype.hasOwnProperty.call(
manifest.fileStats,
'members/jack/.member-work-sync/journal.jsonl'
)
).toBe(false);
} finally {
service.dispose();
}

View file

@ -476,6 +476,32 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
expect(result.args).toContain('--anthropic-safe-passthrough');
});
it('passes Anthropic-compatible bearer env to non-Anthropic leads without injecting ANTHROPIC_API_KEY', async () => {
const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({
env: {
ANTHROPIC_BASE_URL: 'http://localhost:11434',
ANTHROPIC_AUTH_TOKEN: 'ollama',
ANTHROPIC_API_KEY: '',
},
authSource: 'anthropic_auth_token',
geminiRuntimeAuth: null,
providerArgs: ['--anthropic-compatible-passthrough'],
});
const result = await (svc as any).buildCrossProviderMemberArgs(
'codex',
[{ name: 'bob', providerId: 'anthropic', model: 'qwen3.6' }],
{ teamRuntimeAuth: { teamName: 'mixed-team', authMaterialId: 'run-1' } }
);
expect(result.usesAnthropicApiKeyHelper).toBe(false);
expect(result.envPatch.ANTHROPIC_BASE_URL).toBe('http://localhost:11434');
expect(result.envPatch.ANTHROPIC_AUTH_TOKEN).toBe('ollama');
expect(result.envPatch.ANTHROPIC_API_KEY).toBe('');
expect(result.args).toContain('--anthropic-compatible-passthrough');
});
it('does not inherit lead effort for an Anthropic teammate with an explicit model', async () => {
const svc = new TeamProvisioningService();
@ -3161,6 +3187,24 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
expect(result.env.ANTHROPIC_API_KEY).toBe('proxy-token');
});
it('preserves Anthropic-compatible Ollama auth token without mapping it into ANTHROPIC_API_KEY', async () => {
const svc = new TeamProvisioningService();
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
ANTHROPIC_BASE_URL: 'http://localhost:11434',
ANTHROPIC_AUTH_TOKEN: 'ollama',
ANTHROPIC_API_KEY: '',
PATH: '/usr/bin',
SHELL: '/bin/zsh',
});
const result = await (svc as any).buildProvisioningEnv();
expect(result.authSource).toBe('anthropic_auth_token');
expect(result.env.ANTHROPIC_BASE_URL).toBe('http://localhost:11434');
expect(result.env.ANTHROPIC_AUTH_TOKEN).toBe('ollama');
expect(result.env.ANTHROPIC_API_KEY).toBe('');
});
it('prefers explicit ANTHROPIC_API_KEY over ANTHROPIC_AUTH_TOKEN', async () => {
const svc = new TeamProvisioningService();
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({

View file

@ -377,7 +377,8 @@ describe('TeamModelSelector disabled Codex models', () => {
const groupLabels = Array.from(
host.querySelectorAll('[data-testid="team-model-selector-opencode-group"] h4')
).map((heading) => heading.textContent ?? '');
expect(groupLabels).toContain('Other OpenCode catalog');
expect(groupLabels).toContain('OpenCode');
expect(groupLabels).toContain('OpenRouter');
expect(host.textContent).toContain('OpenCode');
expect(host.textContent).toContain('OpenRouter');
@ -1401,6 +1402,60 @@ describe('TeamModelSelector disabled Codex models', () => {
});
});
it('points missing OpenCode runtime users to the home page install button', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {
providers: [
{
providerId: 'opencode',
supported: false,
authenticated: false,
statusMessage: 'OpenCode runtime missing',
detailMessage: 'No JSON object found in CLI output',
capabilities: { teamLaunch: false },
models: [],
},
],
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onProviderChange = vi.fn();
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'anthropic',
onProviderChange,
value: '',
onValueChange: () => undefined,
})
);
await Promise.resolve();
});
const openCodeButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('OpenCode')
);
await act(async () => {
openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(onProviderChange).not.toHaveBeenCalled();
expect(host.textContent).toContain('OpenCode is not ready for team launch');
expect(host.textContent).toContain(
'OpenCode is not installed, not found, or the detected runtime is not supported. Install or update OpenCode, then refresh provider status. You can also use the Install button on the home page.'
);
expect(host.textContent).toContain('Reason: No JSON object found in CLI output');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('uses backend OpenCode readiness detail as the disabled reason', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {
@ -2111,6 +2166,7 @@ describe('TeamModelSelector disabled Codex models', () => {
);
expect(openRouterButton).toBeTruthy();
expect(openRouterButton?.textContent).not.toContain('OpenRouter');
await act(async () => {
openRouterButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
@ -2409,10 +2465,9 @@ describe('TeamModelSelector disabled Codex models', () => {
expect(host.textContent).toContain('big-pickle');
expect(host.textContent).toContain('GPT-5.4');
expect(host.textContent).toContain('moonshotai/kimi-k2');
expect(host.textContent).toContain('OpenCode');
expect(host.textContent).toContain('OpenAI');
expect(host.textContent).toContain('OpenRouter');
expect(host.textContent).toContain('Free built-in');
expect(host.textContent).toContain('Other OpenCode catalog');
await act(async () => {
root.unmount();
@ -2420,7 +2475,7 @@ describe('TeamModelSelector disabled Codex models', () => {
});
});
it('groups OpenCode catalog routes by configured, free, connected, and other sources', async () => {
it('groups OpenCode catalog routes by source provider and keeps route badges', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {
providers: [
@ -2581,10 +2636,14 @@ describe('TeamModelSelector disabled Codex models', () => {
await Promise.resolve();
});
expect(host.textContent).toContain('OpenCode config');
expect(host.textContent).toContain('Free built-in');
expect(host.textContent).toContain('Connected providers');
expect(host.textContent).toContain('Other OpenCode catalog');
const sourceGroupLabels = Array.from(
host.querySelectorAll('[data-testid="team-model-selector-opencode-group"] h4')
).map((heading) => heading.textContent ?? '');
expect(sourceGroupLabels).toEqual(
expect.arrayContaining(['llama.cpp', 'OpenCode', 'OpenRouter', 'DeepSeek'])
);
expect(sourceGroupLabels).not.toContain('OpenCode config');
expect(sourceGroupLabels).not.toContain('Connected providers');
expect(host.textContent).toContain('Local');
expect(host.textContent).toContain('Needs test');
expect(host.textContent).toContain('Connected');

View file

@ -113,6 +113,16 @@ function createActions(): RuntimeProviderManagementActions {
};
}
function setInputValue(input: HTMLInputElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
if (!setter) {
throw new Error('HTMLInputElement value setter not found');
}
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
describe('RuntimeProviderManagementPanelView', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
@ -146,17 +156,21 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(host.textContent).toContain('Checking runtime');
expect(host.textContent).toContain('Loading managed OpenCode runtime');
expect(host.textContent).toContain('Loading OpenCode providers');
expect(host.querySelector('[data-testid="runtime-provider-loading-skeleton"]')).not.toBeNull();
expect(host.querySelectorAll('.skeleton-shimmer').length).toBeGreaterThanOrEqual(10);
expect(host.textContent).toContain('Loading OpenCode model routes');
expect(
host.querySelector('[data-testid="runtime-provider-model-loading-skeleton"]')
).not.toBeNull();
expect(host.querySelectorAll('.skeleton-shimmer').length).toBeGreaterThanOrEqual(8);
expect(host.textContent).toContain('Checking...');
const refreshButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Checking...')
);
expect(refreshButton?.disabled).toBe(true);
expect(host.textContent).not.toContain('No launchable OpenCode model routes were reported yet');
});
it('shows the project as a compact operation context, not a selected global profile', async () => {
it('keeps project context out of the runtime summary and labels it as validation context', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
@ -164,7 +178,27 @@ describe('RuntimeProviderManagementPanelView', () => {
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState(),
state: createState({
view: {
...createState().view!,
configuredModels: [
{
providerId: 'llama.cpp',
modelId: 'llama.cpp/qwen-test:0.5b',
displayName: 'qwen-test:0.5b',
sourceLabel: 'llama.cpp',
free: false,
default: false,
availability: 'available',
accessKind: 'verified',
routeKind: 'configured_local',
proofState: 'verified',
requiresExecutionProof: false,
accessReason: null,
},
],
},
}),
actions: createActions(),
disabled: false,
projectPath: '/Users/belief/dev/projects/321',
@ -173,12 +207,13 @@ describe('RuntimeProviderManagementPanelView', () => {
await Promise.resolve();
});
expect(host.textContent).toContain('Project context: 321');
expect(host.textContent).toContain('OpenCode defaults');
expect(host.textContent).toContain('Validation context');
expect(host.textContent).toContain('Tests use 321. Default applies unless');
expect(host.textContent).not.toContain('Project context: 321');
expect(host.textContent).not.toContain('Current context: 321');
expect(host.textContent).not.toContain('Managing selected project profile');
expect(host.textContent).not.toContain('/Users/belief/dev/projects/321');
expect(
host.querySelector('[title="Current project context: /Users/belief/dev/projects/321"]')
).not.toBeNull();
});
it('renders configured OpenCode model routes with local proof actions', async () => {
@ -237,7 +272,7 @@ describe('RuntimeProviderManagementPanelView', () => {
await Promise.resolve();
});
await act(async () => {
buttons.find((button) => button.textContent?.includes('Set project default'))?.click();
buttons.find((button) => button.textContent?.includes('Set all-projects default'))?.click();
await Promise.resolve();
});
@ -246,7 +281,7 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(actions.setDefaultModel).toHaveBeenCalledWith(
'llama.cpp',
'llama.cpp/qwen-test:0.5b',
'project'
'all_projects'
);
});
@ -287,12 +322,6 @@ describe('RuntimeProviderManagementPanelView', () => {
await Promise.resolve();
});
await act(async () => {
Array.from(host.querySelectorAll('button'))
.find((button) => button.textContent?.includes('All projects'))
?.click();
await Promise.resolve();
});
await act(async () => {
Array.from(host.querySelectorAll('button'))
.find((button) => button.textContent?.includes('Set all-projects default'))
@ -300,7 +329,10 @@ describe('RuntimeProviderManagementPanelView', () => {
await Promise.resolve();
});
expect(host.textContent).toContain('Used by project contexts without their own OpenCode default');
expect(host.textContent).toContain(
'Default for every project that does not have its own OpenCode override'
);
expect(host.textContent).toContain('Validation context');
expect(actions.setDefaultModel).toHaveBeenCalledWith(
'llama.cpp',
'llama.cpp/qwen-test:0.5b',
@ -308,6 +340,80 @@ describe('RuntimeProviderManagementPanelView', () => {
);
});
it('filters launchable OpenCode model routes by route text', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const localModel = {
providerId: 'llama.cpp',
modelId: 'llama.cpp/qwen-test:0.5b',
displayName: 'qwen-test:0.5b',
sourceLabel: 'llama.cpp',
free: false,
default: false,
availability: 'available' as const,
accessKind: 'verified' as const,
routeKind: 'configured_local' as const,
proofState: 'verified' as const,
requiresExecutionProof: false,
accessReason: null,
};
const freeModel = {
providerId: 'opencode',
modelId: 'opencode/big-pickle',
displayName: 'big-pickle',
sourceLabel: 'OpenCode',
free: true,
default: false,
availability: 'available' as const,
accessKind: 'builtin_free' as const,
routeKind: 'builtin_free' as const,
proofState: 'not_required' as const,
requiresExecutionProof: false,
accessReason: null,
};
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({
view: {
...createState().view!,
configuredModels: [localModel, freeModel],
},
}),
actions: createActions(),
disabled: false,
projectPath: '/tmp/project-a',
})
);
await Promise.resolve();
});
const searchInput = host.querySelector<HTMLInputElement>(
'input[placeholder="Search model routes"]'
);
expect(searchInput).not.toBeNull();
expect(host.textContent).toContain('qwen-test:0.5b');
expect(host.textContent).toContain('big-pickle');
await act(async () => {
setInputValue(searchInput!, 'pickle');
await Promise.resolve();
});
expect(host.textContent).not.toContain('qwen-test:0.5b');
expect(host.textContent).toContain('big-pickle');
await act(async () => {
setInputValue(searchInput!, 'missing-route');
await Promise.resolve();
});
expect(host.textContent).toContain('No OpenCode model routes match');
expect(host.textContent).toContain('missing-route');
});
it('opens launchable routes first when they exist and keeps providers in a separate tab', async () => {
const host = document.createElement('div');
document.body.appendChild(host);

View file

@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest';
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
import { describe, expect, it } from 'vitest';
describe('resolveAnthropicLaunchModel', () => {
it('keeps legacy long-context fallback behavior when no runtime catalog is available', () => {
@ -84,6 +83,22 @@ describe('resolveAnthropicLaunchModel', () => {
).toBe('opus[1m]');
});
it('preserves explicit Anthropic-compatible model ids instead of manufacturing 1M variants', () => {
expect(
resolveAnthropicLaunchModel({
selectedModel: 'qwen3.6',
limitContext: false,
})
).toBe('qwen3.6');
expect(
resolveAnthropicLaunchModel({
selectedModel: 'qwen3.6',
limitContext: false,
availableLaunchModels: ['qwen3.6'],
})
).toBe('qwen3.6');
});
it('honors explicit 1M Sonnet selections unless 200K context is requested', () => {
expect(
resolveAnthropicLaunchModel({