diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index c959a381..c6b72c9f 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -78,6 +78,7 @@ import { registerTerminalHandlers, removeTerminalHandlers, } from './terminal'; +import { registerTmuxHandlers, removeTmuxHandlers } from './tmux'; import { initializeUpdaterHandlers, registerUpdaterHandlers, @@ -241,6 +242,7 @@ export function initializeIpcHandlers( if (ptyTerminal) { registerTerminalHandlers(ipcMain); } + registerTmuxHandlers(ipcMain); if (httpServerDeps) { registerHttpServerHandlers(ipcMain); } @@ -279,6 +281,7 @@ export function removeIpcHandlers(): void { removeScheduleHandlers(ipcMain); removeCliInstallerHandlers(ipcMain); removeTerminalHandlers(ipcMain); + removeTmuxHandlers(ipcMain); removeHttpServerHandlers(ipcMain); removeExtensionHandlers(ipcMain); removeSkillsHandlers(ipcMain); diff --git a/src/main/ipc/tmux.ts b/src/main/ipc/tmux.ts new file mode 100644 index 00000000..1b6301fc --- /dev/null +++ b/src/main/ipc/tmux.ts @@ -0,0 +1,138 @@ +import { TMUX_GET_STATUS } from '@preload/constants/ipcChannels'; +import { getErrorMessage } from '@shared/utils/errorHandling'; +import { createLogger } from '@shared/utils/logger'; +import { execFile } from 'child_process'; + +import type { TmuxPlatform, TmuxStatus, IpcResult } from '@shared/types'; +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; + +const logger = createLogger('IPC:tmux'); + +let cachedStatus: { value: TmuxStatus; at: number } | null = null; +let statusInFlight: Promise | null = null; +const STATUS_CACHE_TTL_MS = 10_000; + +function mapPlatform(platform: NodeJS.Platform): TmuxPlatform { + if (platform === 'darwin' || platform === 'linux' || platform === 'win32') { + return platform; + } + return 'unknown'; +} + +function execFileAsync( + command: string, + args: string[], + timeout: number +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile(command, args, { timeout }, (error, stdout, stderr) => { + if (error) { + reject(error); + return; + } + resolve({ stdout: String(stdout), stderr: String(stderr) }); + }); + }); +} + +async function resolveBinaryPath(platform: TmuxPlatform): Promise { + const locator = platform === 'win32' ? 'where' : 'which'; + try { + const { stdout } = await execFileAsync(locator, ['tmux'], 2_000); + const firstLine = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + return firstLine ?? null; + } catch { + return null; + } +} + +async function computeTmuxStatus(): Promise { + const platform = mapPlatform(process.platform); + const nativeSupported = platform === 'darwin' || platform === 'linux'; + const checkedAt = new Date().toISOString(); + + try { + const { stdout, stderr } = await execFileAsync('tmux', ['-V'], 3_000); + const version = (stdout || stderr).trim() || null; + const binaryPath = await resolveBinaryPath(platform); + return { + available: true, + version, + binaryPath, + platform, + nativeSupported, + checkedAt, + error: null, + }; + } catch (error) { + const message = getErrorMessage(error); + const missing = + typeof error === 'object' && + error !== null && + 'code' in error && + ((error as { code?: string }).code === 'ENOENT' || + (error as { code?: string }).code === 'ENOEXEC'); + + if (missing) { + return { + available: false, + version: null, + binaryPath: null, + platform, + nativeSupported, + checkedAt, + error: null, + }; + } + + logger.warn(`tmux status check failed: ${message}`); + return { + available: false, + version: null, + binaryPath: null, + platform, + nativeSupported, + checkedAt, + error: message, + }; + } +} + +async function handleGetStatus(_event: IpcMainInvokeEvent): Promise> { + try { + if (cachedStatus && Date.now() - cachedStatus.at < STATUS_CACHE_TTL_MS) { + return { success: true, data: cachedStatus.value }; + } + + if (!statusInFlight) { + statusInFlight = computeTmuxStatus() + .then((status) => { + cachedStatus = { value: status, at: Date.now() }; + return status; + }) + .finally(() => { + statusInFlight = null; + }); + } + + const status = await statusInFlight; + return { success: true, data: status }; + } catch (error) { + const message = getErrorMessage(error); + logger.error('Error in tmux:getStatus:', message); + return { success: false, error: message }; + } +} + +export function registerTmuxHandlers(ipcMain: IpcMain): void { + ipcMain.handle(TMUX_GET_STATUS, handleGetStatus); + logger.info('tmux handlers registered'); +} + +export function removeTmuxHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler(TMUX_GET_STATUS); + logger.info('tmux handlers removed'); +} diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 8a9586f2..b159c4cf 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -429,6 +429,9 @@ export const CLI_INSTALLER_PROGRESS = 'cliInstaller:progress'; /** Invalidate cached CLI status (forces fresh check on next getStatus) */ export const CLI_INSTALLER_INVALIDATE_STATUS = 'cliInstaller:invalidateStatus'; +/** Get current tmux runtime availability for dashboard diagnostics */ +export const TMUX_GET_STATUS = 'tmux:getStatus'; + // ============================================================================= // Terminal API Channels // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index 0ec956dc..f79543ae 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -13,6 +13,7 @@ import { CLI_INSTALLER_INSTALL, CLI_INSTALLER_INVALIDATE_STATUS, CLI_INSTALLER_PROGRESS, + TMUX_GET_STATUS, CONTEXT_CHANGED, CONTEXT_GET_ACTIVE, CONTEXT_LIST, @@ -290,6 +291,7 @@ import type { ToolApprovalEvent, ToolApprovalFileContent, ToolApprovalSettings, + TmuxStatus, TriggerTestResult, UpdateKanbanPatch, UpdateSchedulePatch, @@ -1368,6 +1370,12 @@ const electronAPI: ElectronAPI = { }, }, + tmux: { + getStatus: async (): Promise => { + return invokeIpcWithResult(TMUX_GET_STATUS); + }, + }, + // ===== Terminal API ===== terminal: { spawn: (options?: PtySpawnOptions) => invokeIpcWithResult(TERMINAL_SPAWN, options), diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 1721b303..93bd3307 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -61,6 +61,8 @@ import type { TeamSummary, TeamTask, TeamTaskStatus, + TmuxAPI, + TmuxStatus, TriggerTestResult, UpdateKanbanPatch, UpdaterAPI, @@ -1086,6 +1088,18 @@ export class HttpAPIClient implements ElectronAPI { }, }; + tmux: TmuxAPI = { + getStatus: async (): Promise => ({ + available: true, + version: null, + binaryPath: null, + platform: 'unknown', + nativeSupported: true, + checkedAt: new Date().toISOString(), + error: null, + }), + }; + // --------------------------------------------------------------------------- // Terminal (not available in browser mode) // --------------------------------------------------------------------------- diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 5b90c91d..3ec47ef8 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -40,6 +40,7 @@ import { import { CliStatusBanner } from './CliStatusBanner'; import { DashboardUpdateBanner } from './DashboardUpdateBanner'; +import { TmuxStatusBanner } from './TmuxStatusBanner'; import type { RepositoryGroup } from '@renderer/types/data'; import type { TeamSummary } from '@shared/types'; @@ -837,6 +838,7 @@ export const DashboardView = (): React.JSX.Element => { {/* CLI Status Banner */} + {/* Team select + Search */}
diff --git a/src/renderer/components/dashboard/TmuxStatusBanner.tsx b/src/renderer/components/dashboard/TmuxStatusBanner.tsx new file mode 100644 index 00000000..0392d99f --- /dev/null +++ b/src/renderer/components/dashboard/TmuxStatusBanner.tsx @@ -0,0 +1,219 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { api, isElectronMode } from '@renderer/api'; +import { AlertTriangle, ExternalLink, RefreshCw, Wrench } from 'lucide-react'; + +import type { TmuxStatus } from '@shared/types'; + +const OFFICIAL_TMUX_INSTALL_URL = 'https://github.com/tmux/tmux/wiki/Installing'; + +type BannerState = + | { loading: true; status: null; error: null } + | { loading: false; status: TmuxStatus; error: null } + | { loading: false; status: null; error: string }; + +const INITIAL_STATE: BannerState = { loading: true, status: null, error: null }; + +function PlatformInstallMatrix(): React.JSX.Element { + return ( +
+
+
+ macOS +
+
+
Homebrew
+ brew install tmux +
MacPorts
+ port install tmux +
+
+ +
+
+ Linux +
+
+ apt install tmux + dnf install tmux + yum install tmux + zypper install tmux + pacman -S tmux +
+
+ +
+
+ Windows +
+
+

В official tmux wiki нет native Windows install command.

+

+ Рекомендуемый путь: WSL, затем внутри Linux-дистрибутива использовать одну из Linux + команд выше, например apt install tmux. +

+
+
+
+ ); +} + +function getPrimaryDetail(status: TmuxStatus): string { + if (status.platform === 'darwin') { + return 'На macOS проще всего поставить tmux через Homebrew или MacPorts.'; + } + if (status.platform === 'linux') { + return 'На Linux команда зависит от дистрибутива: apt, dnf, yum, zypper или pacman.'; + } + if (status.platform === 'win32') { + return 'На Windows у official tmux wiki нет native installer; safest путь — WSL и установка tmux внутри Linux-дистрибутива.'; + } + return 'Поставь tmux через пакетный менеджер своей ОС.'; +} + +export const TmuxStatusBanner = (): React.JSX.Element | null => { + const isElectron = useMemo(() => isElectronMode(), []); + const [state, setState] = useState(INITIAL_STATE); + + const fetchStatus = useCallback(async () => { + setState( + (prev) => + ({ + loading: true, + status: prev.status, + error: null, + }) as BannerState + ); + + try { + const status = await api.tmux.getStatus(); + setState({ loading: false, status, error: null }); + } catch (error) { + setState({ + loading: false, + status: null, + error: error instanceof Error ? error.message : 'Failed to check tmux status', + }); + } + }, []); + + useEffect(() => { + if (!isElectron) { + return; + } + void fetchStatus(); + }, [fetchStatus, isElectron]); + + if (!isElectron) return null; + if (state.loading && !state.status) return null; + + if (state.error && !state.status) { + return ( +
+
+
+ +
+
+ Failed to check tmux availability +
+

+ {state.error} +

+
+
+ +
+
+ ); + } + + if (!state.status || state.status.available) { + return null; + } + + return ( +
+
+
+ +
+
+ tmux is not installed +
+

+ Persistent team agents работают стабильнее в process/tmux path. Без tmux app остаётся + на более тяжёлом in-process пути. {getPrimaryDetail(state.status)} +

+ {state.status.error && ( +

+ Last check error: {state.status.error} +

+ )} +
+
+ +
+ + +
+
+ + +
+ ); +}; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index e7994209..21bf41fc 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -81,6 +81,7 @@ import type { UpdateKanbanPatch, } from './team'; import type { TerminalAPI } from './terminal'; +import type { TmuxAPI } from './tmux'; import type { WaterfallData } from './visualization'; import type { ConversationGroup, @@ -823,6 +824,9 @@ export interface ElectronAPI { // CLI Installer API cliInstaller: CliInstallerAPI; + // tmux runtime diagnostics API + tmux: TmuxAPI; + // Embedded Terminal API (xterm.js + node-pty) terminal: TerminalAPI; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 304825f0..11c85a02 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -44,3 +44,6 @@ export type * from './editor'; // Re-export Extension Store types (inferCapabilities is re-exported from extensionNormalizers) export type * from './extensions'; + +// Re-export tmux runtime status types +export type * from './tmux'; diff --git a/src/shared/types/tmux.ts b/src/shared/types/tmux.ts new file mode 100644 index 00000000..d6c26099 --- /dev/null +++ b/src/shared/types/tmux.ts @@ -0,0 +1,15 @@ +export type TmuxPlatform = 'darwin' | 'linux' | 'win32' | 'unknown'; + +export interface TmuxStatus { + available: boolean; + version: string | null; + binaryPath: string | null; + platform: TmuxPlatform; + nativeSupported: boolean; + checkedAt: string; + error: string | null; +} + +export interface TmuxAPI { + getStatus: () => Promise; +}