diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts index 1b494a68..fa53ee49 100644 --- a/backend/src/routes/health.ts +++ b/backend/src/routes/health.ts @@ -17,7 +17,10 @@ export function createHealthRoutes(db: Database) { timestamp: new Date().toISOString(), database: dbCheck ? 'connected' : 'disconnected', opencode: opencodeHealthy ? 'healthy' : 'unhealthy', - opencodePort: opencodeServerManager.getPort() + opencodePort: opencodeServerManager.getPort(), + opencodeVersion: opencodeServerManager.getVersion(), + opencodeMinVersion: opencodeServerManager.getMinVersion(), + opencodeVersionSupported: opencodeServerManager.isVersionSupported() }) } catch (error) { return c.json({ diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 2acf5c42..6afc1615 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -27,25 +27,7 @@ const UpdateOpenCodeConfigSchema = z.object({ isDefault: z.boolean().optional(), }) -function hasMcpChanged(oldContent: Record, newContent: Record): boolean { - const oldMcp = oldContent.mcp as Record || {} - const newMcp = newContent.mcp as Record || {} - - const oldKeys = Object.keys(oldMcp).sort() - const newKeys = Object.keys(newMcp).sort() - - if (JSON.stringify(oldKeys) !== JSON.stringify(newKeys)) { - return true - } - - for (const key of oldKeys) { - if (JSON.stringify(oldMcp[key]) !== JSON.stringify(newMcp[key])) { - return true - } - } - - return false -} + const CreateCustomCommandSchema = z.object({ name: z.string().min(1).max(255), @@ -170,11 +152,6 @@ export function createSettingsRoutes(db: Database) { logger.info(`Wrote default config to: ${configPath}`) await patchOpenCodeConfig(config.content) - - if (existingConfig && hasMcpChanged(existingConfig.content, config.content)) { - logger.info('MCP configuration changed, restarting OpenCode server') - await opencodeServerManager.restart() - } } return c.json(config) diff --git a/backend/src/services/opencode-single-server.ts b/backend/src/services/opencode-single-server.ts index 8b1ce6c2..4001ce63 100644 --- a/backend/src/services/opencode-single-server.ts +++ b/backend/src/services/opencode-single-server.ts @@ -8,13 +8,28 @@ import type { Database } from 'bun:sqlite' const OPENCODE_SERVER_PORT = ENV.OPENCODE.PORT const OPENCODE_SERVER_DIRECTORY = getWorkspacePath() const OPENCODE_CONFIG_PATH = getOpenCodeConfigFilePath() +const MIN_OPENCODE_VERSION = '1.0.137' + +function compareVersions(v1: string, v2: string): number { + const parts1 = v1.split('.').map(Number) + const parts2 = v2.split('.').map(Number) + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const p1 = parts1[i] || 0 + const p2 = parts2[i] || 0 + if (p1 > p2) return 1 + if (p1 < p2) return -1 + } + return 0 +} class OpenCodeServerManager { private static instance: OpenCodeServerManager - private serverProcess: any = null + private serverProcess: ReturnType | null = null private serverPid: number | null = null private isHealthy: boolean = false private db: Database | null = null + private version: string | null = null private constructor() {} @@ -105,7 +120,7 @@ class OpenCodeServerManager { } ) - this.serverPid = this.serverProcess.pid + this.serverPid = this.serverProcess.pid ?? null logger.info(`OpenCode server started with PID ${this.serverPid}`) @@ -116,6 +131,15 @@ class OpenCodeServerManager { this.isHealthy = true logger.info('OpenCode server is healthy') + + await this.fetchVersion() + if (this.version) { + logger.info(`OpenCode version: ${this.version}`) + if (!this.isVersionSupported()) { + logger.warn(`OpenCode version ${this.version} is below minimum required version ${MIN_OPENCODE_VERSION}`) + logger.warn('Some features like MCP management may not work correctly') + } + } } async stop(): Promise { @@ -152,6 +176,19 @@ class OpenCodeServerManager { return OPENCODE_SERVER_PORT } + getVersion(): string | null { + return this.version + } + + getMinVersion(): string { + return MIN_OPENCODE_VERSION + } + + isVersionSupported(): boolean { + if (!this.version) return false + return compareVersions(this.version, MIN_OPENCODE_VERSION) >= 0 + } + async checkHealth(): Promise { try { const response = await fetch(`http://127.0.0.1:${OPENCODE_SERVER_PORT}/doc`, { @@ -163,6 +200,20 @@ class OpenCodeServerManager { } } + async fetchVersion(): Promise { + try { + const result = execSync('opencode --version 2>&1', { encoding: 'utf8' }) + const match = result.match(/(\d+\.\d+\.\d+)/) + if (match && match[1]) { + this.version = match[1] + return this.version + } + } catch (error) { + logger.warn('Failed to get OpenCode version:', error) + } + return null + } + private async waitForHealth(timeoutMs: number): Promise { const start = Date.now() while (Date.now() - start < timeoutMs) { diff --git a/frontend/src/api/mcp.ts b/frontend/src/api/mcp.ts new file mode 100644 index 00000000..6d5d9c3b --- /dev/null +++ b/frontend/src/api/mcp.ts @@ -0,0 +1,127 @@ +import { API_BASE_URL } from '@/config' + +const API_BASE = API_BASE_URL + +export type McpStatus = + | { status: 'connected' } + | { status: 'disabled' } + | { status: 'failed'; error: string } + | { status: 'needs_auth' } + | { status: 'needs_client_registration'; error: string } + +export type McpStatusMap = Record + +export interface McpServerConfig { + type: 'local' | 'remote' + enabled?: boolean + command?: string[] + url?: string + environment?: Record + headers?: Record + timeout?: number + oauth?: boolean | { + clientId?: string + clientSecret?: string + scope?: string + } +} + +export interface AddMcpServerRequest { + name: string + config: McpServerConfig +} + +export interface McpAuthStartResponse { + authorizationUrl: string +} + +export const mcpApi = { + async getStatus(): Promise { + const response = await fetch(`${API_BASE}/api/opencode/mcp`) + if (!response.ok) { + throw new Error(`Failed to get MCP status: ${response.statusText}`) + } + return response.json() + }, + + async addServer(name: string, config: McpServerConfig): Promise { + const response = await fetch(`${API_BASE}/api/opencode/mcp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, config }), + }) + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })) + throw new Error(error.error || `Failed to add MCP server: ${response.statusText}`) + } + return response.json() + }, + + async connect(name: string): Promise { + const response = await fetch(`${API_BASE}/api/opencode/mcp/${encodeURIComponent(name)}/connect`, { + method: 'POST', + }) + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })) + throw new Error(error.error || `Failed to connect MCP server: ${response.statusText}`) + } + return response.json() + }, + + async disconnect(name: string): Promise { + const response = await fetch(`${API_BASE}/api/opencode/mcp/${encodeURIComponent(name)}/disconnect`, { + method: 'POST', + }) + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })) + throw new Error(error.error || `Failed to disconnect MCP server: ${response.statusText}`) + } + return response.json() + }, + + async startAuth(name: string): Promise { + const response = await fetch(`${API_BASE}/api/opencode/mcp/${encodeURIComponent(name)}/auth`, { + method: 'POST', + }) + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })) + throw new Error(error.error || `Failed to start MCP auth: ${response.statusText}`) + } + return response.json() + }, + + async completeAuth(name: string, code: string): Promise { + const response = await fetch(`${API_BASE}/api/opencode/mcp/${encodeURIComponent(name)}/auth/callback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }) + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })) + throw new Error(error.error || `Failed to complete MCP auth: ${response.statusText}`) + } + return response.json() + }, + + async authenticate(name: string): Promise { + const response = await fetch(`${API_BASE}/api/opencode/mcp/${encodeURIComponent(name)}/auth/authenticate`, { + method: 'POST', + }) + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })) + throw new Error(error.error || `Failed to authenticate MCP server: ${response.statusText}`) + } + return response.json() + }, + + async removeAuth(name: string): Promise<{ success: true }> { + const response = await fetch(`${API_BASE}/api/opencode/mcp/${encodeURIComponent(name)}/auth`, { + method: 'DELETE', + }) + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })) + throw new Error(error.error || `Failed to remove MCP auth: ${response.statusText}`) + } + return response.json() + }, +} diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 5c25c3df..c677666c 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -8,7 +8,8 @@ import { useModelSelection } from '@/hooks/useModelSelection' import { useUserBash } from '@/stores/userBashStore' import { useMobile } from '@/hooks/useMobile' -import { ChevronDown } from 'lucide-react' +import { useSessionStatusForSession } from '@/stores/sessionStatusStore' +import { ChevronDown, Square } from 'lucide-react' import { CommandSuggestions } from '@/components/command/CommandSuggestions' import { MentionSuggestions, type MentionItem } from './MentionSuggestions' @@ -451,7 +452,9 @@ export function PromptInput({ const { model: selectedModel, modelString } = useModelSelection(opcodeUrl, directory) const currentModel = modelString || '' const isMobile = useMobile() - const hideSecondaryButtons = isMobile && (hasActiveStream || showScrollButton) + const sessionStatus = useSessionStatusForSession(sessionID) + const showStopButton = hasActiveStream && (sessionStatus.type === 'busy' || sessionStatus.type === 'retry') + const hideSecondaryButtons = isMobile && hasActiveStream useEffect(() => { const loadModelName = async () => { @@ -485,7 +488,7 @@ export function PromptInput({ return (
{hasActiveStream && ( -
+
)} @@ -566,14 +569,14 @@ export function PromptInput({ )} - {hasActiveStream && ( +{showStopButton && ( )} )}
@@ -259,7 +285,7 @@ onSuccess: async () => { checked={enabled} onCheckedChange={setEnabled} /> - +
@@ -269,13 +295,13 @@ onSuccess: async () => { ) -} \ No newline at end of file +} diff --git a/frontend/src/components/settings/McpManager.tsx b/frontend/src/components/settings/McpManager.tsx index 50224a5d..54256377 100644 --- a/frontend/src/components/settings/McpManager.tsx +++ b/frontend/src/components/settings/McpManager.tsx @@ -4,14 +4,16 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Dialog, DialogTrigger } from '@/components/ui/dialog' import { Badge } from '@/components/ui/badge' import { Switch } from '@/components/ui/switch' -import { Plus, Trash2, Globe, Terminal, Loader2 } from 'lucide-react' +import { Plus, Trash2, Globe, Terminal, Loader2, AlertCircle, RefreshCw, Key, XCircle } from 'lucide-react' import { DeleteDialog } from '@/components/ui/delete-dialog' import { AddMcpServerDialog } from './AddMcpServerDialog' +import { useMcpServers } from '@/hooks/useMcpServers' import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { McpStatus } from '@/api/mcp' interface McpServerConfig { type: 'local' | 'remote' - enabled: boolean + enabled?: boolean command?: string[] url?: string environment?: Record @@ -27,51 +29,61 @@ interface McpManagerProps { onConfigUpdate?: (configName: string, content: Record) => Promise } +function getStatusBadge(status: McpStatus) { + switch (status.status) { + case 'connected': + return Connected + case 'disabled': + return Disabled + case 'failed': + return ( + + + Failed + + ) + case 'needs_auth': + return ( + + + Auth Required + + ) + case 'needs_client_registration': + return ( + + + Registration Required + + ) + default: + return Unknown + } +} + export function McpManager({ config, onUpdate, onConfigUpdate }: McpManagerProps) { const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [deleteConfirmServer, setDeleteConfirmServer] = useState<{ id: string; name: string } | null>(null) const [togglingServerId, setTogglingServerId] = useState(null) const queryClient = useQueryClient() - - const toggleServerMutation = useMutation({ - mutationFn: async ({ serverId, enabled }: { serverId: string; enabled: boolean }) => { - if (!config) return - - setTogglingServerId(serverId) - - const currentMcp = (config.content?.mcp as Record) || {} - const serverConfig = currentMcp[serverId] - - if (!serverConfig) return - - const updatedConfig = { - ...config.content, - mcp: { - ...currentMcp, - [serverId]: { - ...serverConfig, - enabled, - }, - }, - } - - await onUpdate(updatedConfig) - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['opencode-config'] }) - }, - onSettled: () => { - setTogglingServerId(null) - }, - }) + const { + status: mcpStatus, + isLoading: isLoadingStatus, + refetch: refetchStatus, + connect, + disconnect, + authenticate, + isToggling + } = useMcpServers() const deleteServerMutation = useMutation({ mutationFn: async (serverId: string) => { if (!config) return - const currentMcp = (config.content?.mcp as Record) || {} - const { [serverId]: _deleted, ...rest } = currentMcp + const currentMcp = (config.content?.mcp as Record) || {} + const { [serverId]: _, ...rest } = currentMcp + void _ const updatedConfig = { ...config.content, @@ -82,16 +94,34 @@ export function McpManager({ config, onUpdate, onConfigUpdate }: McpManagerProps }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['opencode-config'] }) + queryClient.invalidateQueries({ queryKey: ['mcp-status'] }) setDeleteConfirmServer(null) }, }) const mcpServers = config?.content?.mcp as Record || {} - const isAnyOperationPending = toggleServerMutation.isPending || deleteServerMutation.isPending || togglingServerId !== null + const isAnyOperationPending = deleteServerMutation.isPending || togglingServerId !== null || isToggling - const handleToggleServer = (serverId: string, enabled: boolean) => { - toggleServerMutation.mutate({ serverId, enabled }) + const handleToggleServer = async (serverId: string) => { + const currentStatus = mcpStatus?.[serverId] + if (!currentStatus) return + + setTogglingServerId(serverId) + try { + if (currentStatus.status === 'connected') { + await disconnect(serverId) + } else if (currentStatus.status === 'disabled') { + await connect(serverId) + } else if (currentStatus.status === 'needs_auth') { + await authenticate(serverId) + } else if (currentStatus.status === 'failed') { + await connect(serverId) + } + } finally { + setTogglingServerId(null) + refetchStatus() + } } const handleDeleteServer = () => { @@ -125,6 +155,14 @@ export function McpManager({ config, onUpdate, onConfigUpdate }: McpManagerProps return 'MCP server' } + const getErrorMessage = (serverId: string): string | null => { + const status = mcpStatus?.[serverId] + if (!status) return null + if (status.status === 'failed') return status.error + if (status.status === 'needs_client_registration') return status.error + return null + } + if (!config) { return (
@@ -155,19 +193,29 @@ export function McpManager({ config, onUpdate, onConfigUpdate }: McpManagerProps Manage Model Context Protocol servers for {config.name}

- - - - - - +
+ + + + + + + +
{Object.keys(mcpServers).length === 0 ? ( @@ -178,58 +226,70 @@ export function McpManager({ config, onUpdate, onConfigUpdate }: McpManagerProps ) : (
- {Object.entries(mcpServers).map(([serverId, serverConfig]) => ( - - -
-
-
- {serverConfig.type === 'local' ? ( - - ) : ( - - )} - {getServerDisplayName(serverId)} + {Object.entries(mcpServers).map(([serverId, serverConfig]) => { + const status = mcpStatus?.[serverId] + const isConnected = status?.status === 'connected' + const errorMessage = getErrorMessage(serverId) + + return ( + + +
+
+
+ {serverConfig.type === 'local' ? ( + + ) : ( + + )} + {getServerDisplayName(serverId)} +
+
+ {status ? getStatusBadge(status) : ( + Loading... + )} + + {serverConfig.type} + +
- - {serverConfig.enabled ? 'Enabled' : 'Disabled'} - - - {serverConfig.type} - + handleToggleServer(serverId)} + disabled={isAnyOperationPending || togglingServerId === serverId} + /> +
-
- handleToggleServer(serverId, enabled)} - disabled={isAnyOperationPending} - /> - + + +
+

{getServerDescription(serverConfig)}

+ {serverConfig.timeout && ( +

Timeout: {serverConfig.timeout}ms

+ )} + {serverConfig.environment && Object.keys(serverConfig.environment).length > 0 && ( +

Environment variables: {Object.keys(serverConfig.environment).length} configured

+ )} + {errorMessage && ( +
+ + {errorMessage} +
+ )}
-
-
- -
-

{getServerDescription(serverConfig)}

- {serverConfig.timeout && ( -

Timeout: {serverConfig.timeout}ms

- )} - {serverConfig.environment && Object.keys(serverConfig.environment).length > 0 && ( -

Environment variables: {Object.keys(serverConfig.environment).length} configured

- )} -
-
-
- ))} + + + ) + })}
)} diff --git a/frontend/src/hooks/useMcpServers.ts b/frontend/src/hooks/useMcpServers.ts new file mode 100644 index 00000000..341cc892 --- /dev/null +++ b/frontend/src/hooks/useMcpServers.ts @@ -0,0 +1,139 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { mcpApi } from '@/api/mcp' +import type { McpStatusMap, McpServerConfig, McpStatus } from '@/api/mcp' +import { showToast as toast } from '@/lib/toast' + +export function useMcpServers() { + const queryClient = useQueryClient() + + const statusQuery = useQuery({ + queryKey: ['mcp-status'], + queryFn: () => mcpApi.getStatus(), + refetchInterval: 5000, + staleTime: 2000, + }) + + const addServerMutation = useMutation({ + mutationFn: ({ name, config }: { name: string; config: McpServerConfig }) => + mcpApi.addServer(name, config), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mcp-status'] }) + toast.success('MCP server added successfully') + }, + onError: (error: Error) => { + toast.error(`Failed to add MCP server: ${error.message}`) + }, + }) + + const connectMutation = useMutation({ + mutationFn: (name: string) => mcpApi.connect(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mcp-status'] }) + toast.success('MCP server connected') + }, + onError: (error: Error) => { + toast.error(`Failed to connect MCP server: ${error.message}`) + }, + }) + + const disconnectMutation = useMutation({ + mutationFn: (name: string) => mcpApi.disconnect(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mcp-status'] }) + toast.success('MCP server disconnected') + }, + onError: (error: Error) => { + toast.error(`Failed to disconnect MCP server: ${error.message}`) + }, + }) + + const startAuthMutation = useMutation({ + mutationFn: (name: string) => mcpApi.startAuth(name), + onError: (error: Error) => { + toast.error(`Failed to start authentication: ${error.message}`) + }, + }) + + const completeAuthMutation = useMutation({ + mutationFn: ({ name, code }: { name: string; code: string }) => + mcpApi.completeAuth(name, code), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mcp-status'] }) + toast.success('Authentication completed') + }, + onError: (error: Error) => { + toast.error(`Failed to complete authentication: ${error.message}`) + }, + }) + + const authenticateMutation = useMutation({ + mutationFn: (name: string) => mcpApi.authenticate(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mcp-status'] }) + toast.success('Authentication completed') + }, + onError: (error: Error) => { + toast.error(`Failed to authenticate: ${error.message}`) + }, + }) + + const removeAuthMutation = useMutation({ + mutationFn: (name: string) => mcpApi.removeAuth(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mcp-status'] }) + toast.success('Authentication credentials removed') + }, + onError: (error: Error) => { + toast.error(`Failed to remove authentication: ${error.message}`) + }, + }) + + const toggleServer = async (name: string, currentStatus: McpStatus) => { + if (currentStatus.status === 'connected') { + return disconnectMutation.mutateAsync(name) + } else if (currentStatus.status === 'disabled') { + return connectMutation.mutateAsync(name) + } else if (currentStatus.status === 'needs_auth') { + return authenticateMutation.mutateAsync(name) + } + } + + return { + status: statusQuery.data as McpStatusMap | undefined, + isLoading: statusQuery.isLoading, + isError: statusQuery.isError, + error: statusQuery.error, + refetch: statusQuery.refetch, + + addServer: addServerMutation.mutate, + addServerAsync: addServerMutation.mutateAsync, + isAddingServer: addServerMutation.isPending, + + connect: connectMutation.mutate, + connectAsync: connectMutation.mutateAsync, + isConnecting: connectMutation.isPending, + + disconnect: disconnectMutation.mutate, + disconnectAsync: disconnectMutation.mutateAsync, + isDisconnecting: disconnectMutation.isPending, + + toggleServer, + isToggling: connectMutation.isPending || disconnectMutation.isPending || authenticateMutation.isPending, + + startAuth: startAuthMutation.mutate, + startAuthAsync: startAuthMutation.mutateAsync, + isStartingAuth: startAuthMutation.isPending, + + completeAuth: completeAuthMutation.mutate, + completeAuthAsync: completeAuthMutation.mutateAsync, + isCompletingAuth: completeAuthMutation.isPending, + + authenticate: authenticateMutation.mutate, + authenticateAsync: authenticateMutation.mutateAsync, + isAuthenticating: authenticateMutation.isPending, + + removeAuth: removeAuthMutation.mutate, + removeAuthAsync: removeAuthMutation.mutateAsync, + isRemovingAuth: removeAuthMutation.isPending, + } +} diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index 92a09220..7b2c5e63 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -24,11 +24,37 @@ fi echo "🔍 Checking OpenCode installation..." +MIN_OPENCODE_VERSION="1.0.137" + +version_gte() { + printf '%s\n%s\n' "$2" "$1" | sort -V -C +} + if ! command -v opencode >/dev/null 2>&1; then - echo "⚠️ OpenCode not found in PATH" -else - OPENCODE_VERSION=$(opencode --version 2>&1 || echo "unknown") - echo "✅ OpenCode is installed (version: $OPENCODE_VERSION)" + echo "⚠️ OpenCode not found. Installing..." + curl -fsSL https://opencode.ai/install | bash + + if ! command -v opencode >/dev/null 2>&1; then + echo "❌ Failed to install OpenCode. Exiting." + exit 1 + fi + echo "✅ OpenCode installed successfully" +fi + +OPENCODE_VERSION=$(opencode --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") +echo "✅ OpenCode is installed (version: $OPENCODE_VERSION)" + +if [ "$OPENCODE_VERSION" != "unknown" ]; then + if version_gte "$OPENCODE_VERSION" "$MIN_OPENCODE_VERSION"; then + echo "✅ OpenCode version meets minimum requirement (>=$MIN_OPENCODE_VERSION)" + else + echo "⚠️ OpenCode version $OPENCODE_VERSION is below minimum required version $MIN_OPENCODE_VERSION" + echo "🔄 Upgrading OpenCode..." + opencode upgrade || curl -fsSL https://opencode.ai/install | bash + + OPENCODE_VERSION=$(opencode --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") + echo "✅ OpenCode upgraded to version: $OPENCODE_VERSION" + fi fi echo "🚀 Starting OpenCode WebUI Backend..."