import { useCallback, useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import type { TmuxStatus } from '@features/tmux-installer/contracts'; import type { TeamProviderId } from '@shared/types'; type TeammateRuntimeIssueReason = | 'mixed-provider' | 'codex-native-runtime' | 'explicit-tmux-mode' | 'explicit-in-process-mode' | 'opencode-led-mixed-unsupported'; interface RuntimeMemberInput { id?: string; name: string; providerId?: TeamProviderId; providerBackendId?: string | null; removedAt?: number | string | null; } interface RuntimeIssue { reason: TeammateRuntimeIssueReason; memberId?: string; memberName?: string; memberProviderId?: TeamProviderId; } export interface TeammateRuntimeCompatibility { visible: boolean; blocksSubmission: boolean; checking: boolean; title: string; message: string; details: string[]; tmuxDetail: string | null; memberWarningById: Record; } interface AnalyzeTeammateRuntimeCompatibilityInput { leadProviderId: TeamProviderId; leadProviderBackendId?: string | null; members: readonly RuntimeMemberInput[]; soloTeam?: boolean; extraCliArgs?: string; tmuxStatus: TmuxStatus | null; tmuxStatusLoading: boolean; tmuxStatusError: string | null; } export interface TmuxRuntimeReadiness { status: TmuxStatus | null; loading: boolean; error: string | null; refresh: () => Promise; } const PROVIDER_LABELS: Record = { anthropic: 'Anthropic', codex: 'Codex', gemini: 'Gemini', opencode: 'OpenCode', }; function getProviderLabel(providerId: TeamProviderId): string { return PROVIDER_LABELS[providerId] ?? providerId; } function getExplicitTeammateMode( rawExtraCliArgs: string | undefined ): 'auto' | 'tmux' | 'in-process' | null { const tokens = parseCliArgs(rawExtraCliArgs); for (let index = 0; index < tokens.length; index += 1) { const token = tokens[index]; // eslint-disable-next-line security/detect-possible-timing-attacks -- parsing UI CLI flags, not comparing secrets if (token === '--teammate-mode') { const value = tokens[index + 1]; return value === 'auto' || value === 'tmux' || value === 'in-process' ? value : null; } if (token.startsWith('--teammate-mode=')) { const value = token.slice('--teammate-mode='.length); return value === 'auto' || value === 'tmux' || value === 'in-process' ? value : null; } } return null; } function isTmuxRuntimeReady(status: TmuxStatus | null): boolean { return status?.effective.available === true && status.effective.runtimeReady === true; } function getTmuxDetail(status: TmuxStatus | null, error: string | null): string | null { if (error) { return error; } return status?.effective.detail ?? status?.wsl?.statusDetail ?? status?.error ?? null; } function summarizeIssueNames( issues: readonly RuntimeIssue[], reason: TeammateRuntimeIssueReason ): string { const names = issues .filter((issue) => issue.reason === reason) .map((issue) => issue.memberName) .filter((name): name is string => Boolean(name)); if (names.length === 0) { return ''; } if (names.length <= 3) { return names.join(', '); } return `${names.slice(0, 3).join(', ')} and ${names.length - 3} more`; } export function analyzeTeammateRuntimeCompatibility({ leadProviderId, leadProviderBackendId, members, soloTeam = false, extraCliArgs, tmuxStatus, tmuxStatusLoading, tmuxStatusError, }: AnalyzeTeammateRuntimeCompatibilityInput): TeammateRuntimeCompatibility { const activeMembers = soloTeam ? [] : members.filter((member) => member.removedAt == null && member.name.trim().length > 0); const explicitTeammateMode = getExplicitTeammateMode(extraCliArgs); const leadBackendId = migrateProviderBackendId(leadProviderId, leadProviderBackendId); const issues: RuntimeIssue[] = []; if (explicitTeammateMode === 'tmux' && activeMembers.length > 0) { issues.push({ reason: 'explicit-tmux-mode' }); } for (const member of activeMembers) { const memberProviderId = normalizeOptionalTeamProviderId(member.providerId) ?? leadProviderId; const memberName = member.name.trim(); if (memberProviderId !== leadProviderId) { if (leadProviderId !== 'opencode' && memberProviderId === 'opencode') { continue; } if (leadProviderId === 'opencode') { issues.push({ reason: 'opencode-led-mixed-unsupported', memberId: member.id, memberName, memberProviderId, }); continue; } issues.push({ reason: 'mixed-provider', memberId: member.id, memberName, memberProviderId, }); continue; } const memberBackendId = migrateProviderBackendId( memberProviderId, member.providerBackendId ?? leadBackendId ); if (memberProviderId === 'codex' && memberBackendId === 'codex-native') { issues.push({ reason: 'codex-native-runtime', memberId: member.id, memberName, memberProviderId, }); } } const requiresSeparateProcess = issues.some( (issue) => issue.reason === 'mixed-provider' || issue.reason === 'codex-native-runtime' ); if (explicitTeammateMode === 'in-process' && requiresSeparateProcess) { issues.push({ reason: 'explicit-in-process-mode' }); } if (issues.length === 0) { return { visible: false, blocksSubmission: false, checking: false, title: '', message: '', details: [], tmuxDetail: null, memberWarningById: {}, }; } const tmuxReady = isTmuxRuntimeReady(tmuxStatus); const hasOpenCodeLeadMixedUnsupported = issues.some( (issue) => issue.reason === 'opencode-led-mixed-unsupported' ); const hasExplicitTmux = issues.some((issue) => issue.reason === 'explicit-tmux-mode'); const hasExplicitInProcess = issues.some((issue) => issue.reason === 'explicit-in-process-mode'); if (!hasOpenCodeLeadMixedUnsupported && !hasExplicitTmux && !hasExplicitInProcess) { return { visible: false, blocksSubmission: false, checking: false, title: '', message: '', details: [], tmuxDetail: null, memberWarningById: {}, }; } if (tmuxReady && hasExplicitTmux && !hasOpenCodeLeadMixedUnsupported && !hasExplicitInProcess) { return { visible: false, blocksSubmission: false, checking: false, title: '', message: '', details: [], tmuxDetail: null, memberWarningById: {}, }; } const checking = hasExplicitTmux && !hasOpenCodeLeadMixedUnsupported && !hasExplicitInProcess && tmuxStatusLoading && !tmuxStatus; const blocksSubmission = true; const hasMixedProviders = issues.some((issue) => issue.reason === 'mixed-provider'); const hasCodexNative = issues.some((issue) => issue.reason === 'codex-native-runtime'); const details: string[] = []; const memberWarningById: Record = {}; if (hasMixedProviders) { const names = summarizeIssueNames(issues, 'mixed-provider'); details.push( names ? `Mixed providers: ${names} use a different provider than the ${getProviderLabel(leadProviderId)} lead.` : 'Mixed providers require teammate processes.' ); } if (hasOpenCodeLeadMixedUnsupported) { const names = summarizeIssueNames(issues, 'opencode-led-mixed-unsupported'); details.push( names ? `OpenCode-led mixed team: ${names} use a non-OpenCode provider.` : 'Mixed teams cannot use OpenCode as the lead in this phase.' ); } if (hasCodexNative) { const names = summarizeIssueNames(issues, 'codex-native-runtime'); details.push( names ? `Codex native teammates: ${names} must run through separate Codex processes.` : 'Codex native teammates must run through separate Codex processes.' ); } if (hasExplicitTmux) { details.push('Custom CLI args force --teammate-mode tmux.'); } if (hasExplicitInProcess) { details.push('Custom CLI args force --teammate-mode in-process.'); } if (hasOpenCodeLeadMixedUnsupported) { details.push( 'Fix: keep the team lead on Anthropic, Codex, or Gemini when mixing OpenCode with other providers.' ); } else if (hasExplicitInProcess) { details.push( 'Fix: remove --teammate-mode in-process so teammates can use native process transport.' ); } else { details.push( 'Fix: install tmux/WSL tmux, or remove --teammate-mode tmux so the app can use native process transport.' ); } for (const issue of issues) { if (!issue.memberId || !issue.memberName) { continue; } if (issue.reason === 'mixed-provider') { memberWarningById[issue.memberId] = `${issue.memberName} uses ${getProviderLabel(issue.memberProviderId ?? leadProviderId)}. ` + `This teammate requires a separate process outside the ${getProviderLabel(leadProviderId)} lead.`; } else if (issue.reason === 'codex-native-runtime') { memberWarningById[issue.memberId] = `${issue.memberName} uses Codex native. Codex native teammates require a separate Codex process.`; } else if (issue.reason === 'opencode-led-mixed-unsupported') { memberWarningById[issue.memberId] = `${issue.memberName} uses ${getProviderLabel(issue.memberProviderId ?? leadProviderId)}. ` + 'OpenCode cannot be the team lead when mixing providers in this phase.'; } } return { visible: blocksSubmission || checking, blocksSubmission, checking, title: checking ? 'Checking tmux runtime for explicit teammate mode' : hasOpenCodeLeadMixedUnsupported ? 'OpenCode cannot lead mixed-provider teams' : hasExplicitInProcess ? 'This team cannot use in-process teammates' : 'tmux is not ready for explicit teammate mode', message: checking ? 'Custom CLI args request tmux teammates. The app is checking whether tmux is available.' : hasOpenCodeLeadMixedUnsupported ? 'OpenCode can be added as a teammate under an Anthropic, Codex, or Gemini lead, but mixed teams cannot use OpenCode as the lead in this phase.' : hasExplicitInProcess ? 'Some teammates require separate processes. Remove --teammate-mode in-process so the app can use native process transport.' : 'Custom CLI args force --teammate-mode tmux, but tmux is not ready. Remove that arg to use native process transport on Windows, or install tmux/WSL tmux.', details, tmuxDetail: getTmuxDetail(tmuxStatus, tmuxStatusError), memberWarningById, }; } export function useTmuxRuntimeReadiness(enabled: boolean): TmuxRuntimeReadiness { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(enabled); const [error, setError] = useState(null); const refresh = useCallback(async () => { if (!enabled) { setLoading(false); return; } setLoading(true); setError(null); try { if (typeof api.tmux?.getStatus !== 'function') { throw new Error('tmux status API is not available. Restart the app.'); } const nextStatus = await api.tmux.getStatus(); setStatus(nextStatus); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : 'Failed to load tmux status'); setStatus(null); } finally { setLoading(false); } }, [enabled]); useEffect(() => { if (!enabled) { setStatus(null); setError(null); setLoading(false); return; } void refresh(); }, [enabled, refresh]); useEffect(() => { if (!enabled) { return undefined; } if (typeof api.tmux?.onProgress !== 'function') { return undefined; } return api.tmux.onProgress(() => { void refresh(); }); }, [enabled, refresh]); const effectiveLoading = enabled && (loading || (!status && !error)); return useMemo( () => ({ status, loading: effectiveLoading, error, refresh, }), [effectiveLoading, error, refresh, status] ); }