agent-ecosystem/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts
2026-04-24 16:18:12 +03:00

235 lines
7.7 KiB
TypeScript

import { execFile, execFileSync } from 'node:child_process';
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { TmuxPackageManagerResolver } from '../platform/TmuxPackageManagerResolver';
import { TmuxWslService } from '../wsl/TmuxWslService';
interface ExecResult {
exitCode: number;
stdout: string;
stderr: string;
}
export interface TmuxPaneRuntimeInfo {
paneId: string;
panePid: number;
currentCommand?: string;
currentPath?: string;
sessionName?: string;
windowName?: string;
}
export interface RuntimeProcessTableRow {
pid: number;
ppid: number;
command: string;
}
export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow[] {
const rows: RuntimeProcessTableRow[] = [];
for (const line of output.split('\n')) {
const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
if (!match) continue;
const pid = Number.parseInt(match[1], 10);
const ppid = Number.parseInt(match[2], 10);
const command = match[3]?.trim() ?? '';
if (
Number.isFinite(pid) &&
pid > 0 &&
Number.isFinite(ppid) &&
ppid >= 0 &&
command.length > 0
) {
rows.push({ pid, ppid, command });
}
}
return rows;
}
export class TmuxPlatformCommandExecutor {
readonly #wslService: TmuxWslService;
readonly #packageManagerResolver: TmuxPackageManagerResolver;
constructor(
wslService = new TmuxWslService(),
packageManagerResolver = new TmuxPackageManagerResolver()
) {
this.#wslService = wslService;
this.#packageManagerResolver = packageManagerResolver;
}
async execTmux(args: string[], timeout = 5_000): Promise<ExecResult> {
if (process.platform === 'win32') {
return this.#wslService.execTmux(args, null, timeout);
}
await resolveInteractiveShellEnv();
const env = buildEnrichedEnv();
const executable = await this.#resolveNativeTmuxExecutable(env);
return new Promise((resolve) => {
execFile(executable, args, { env, timeout }, (error, stdout, stderr) => {
const errorCode =
typeof error === 'object' && error !== null && 'code' in error
? (error as NodeJS.ErrnoException).code
: undefined;
resolve({
exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0,
stdout: String(stdout),
stderr: String(stderr) || (error instanceof Error ? error.message : ''),
});
});
});
}
async killPane(paneId: string): Promise<void> {
const result = await this.execTmux(['kill-pane', '-t', paneId], 3_000);
if (result.exitCode !== 0) {
throw new Error(result.stderr || `Failed to kill tmux pane ${paneId}`);
}
}
async listPaneRuntimeInfo(paneIds: readonly string[]): Promise<Map<string, TmuxPaneRuntimeInfo>> {
const normalizedPaneIds = [...new Set(paneIds.map((paneId) => paneId.trim()).filter(Boolean))];
if (normalizedPaneIds.length === 0) {
return new Map();
}
const format = [
'#{pane_id}',
'#{pane_pid}',
'#{pane_current_command}',
'#{pane_current_path}',
'#{session_name}',
'#{window_name}',
].join('\t');
const result = await this.execTmux(['list-panes', '-a', '-F', format], 3_000);
if (result.exitCode !== 0) {
throw new Error(result.stderr || 'Failed to list tmux panes');
}
const wanted = new Set(normalizedPaneIds);
const paneInfoById = new Map<string, TmuxPaneRuntimeInfo>();
for (const line of result.stdout.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const [
paneId = '',
rawPid = '',
currentCommand = '',
currentPath = '',
sessionName = '',
windowName = '',
] = trimmed.split('\t');
const normalizedPaneId = paneId.trim();
if (!wanted.has(normalizedPaneId)) continue;
const pid = Number.parseInt(rawPid.trim(), 10);
if (Number.isFinite(pid) && pid > 0) {
paneInfoById.set(normalizedPaneId, {
paneId: normalizedPaneId,
panePid: pid,
currentCommand: currentCommand.trim() || undefined,
currentPath: currentPath.trim() || undefined,
sessionName: sessionName.trim() || undefined,
windowName: windowName.trim() || undefined,
});
}
}
return paneInfoById;
}
async listPanePids(paneIds: readonly string[]): Promise<Map<string, number>> {
const info = await this.listPaneRuntimeInfo(paneIds);
return new Map([...info.entries()].map(([paneId, pane]) => [paneId, pane.panePid]));
}
async listRuntimeProcesses(): Promise<RuntimeProcessTableRow[]> {
const result =
process.platform === 'win32'
? await this.#wslService.execInPreferredDistro(['ps', '-ax', '-o', 'pid=,ppid=,command='])
: await this.#execNativePs();
if (result.exitCode !== 0) {
throw new Error(result.stderr || 'Failed to list runtime processes');
}
return parseRuntimeProcessTable(result.stdout);
}
killPaneSync(paneId: string): void {
if (process.platform === 'win32') {
const preferredDistro = this.#wslService.getPersistedPreferredDistroSync();
const candidates = this.#getWslExecutableCandidates();
let lastError: Error | null = null;
const distroAttempts = preferredDistro ? [preferredDistro, null] : [null];
for (const distroName of distroAttempts) {
for (const executable of candidates) {
try {
execFileSync(
executable,
[...(distroName ? ['-d', distroName] : []), '-e', 'tmux', 'kill-pane', '-t', paneId],
{
stdio: 'ignore',
windowsHide: true,
}
);
return;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
}
}
}
throw lastError ?? new Error(`Failed to kill tmux pane ${paneId}`);
}
// eslint-disable-next-line sonarjs/no-os-command-from-path -- tmux is resolved during runtime readiness checks before this sync cleanup path is used
execFileSync('tmux', ['kill-pane', '-t', paneId], { stdio: 'ignore' });
}
#getWslExecutableCandidates(): string[] {
const candidates = new Set<string>();
const windir = process.env.WINDIR;
if (windir) {
candidates.add(`${windir}\\System32\\wsl.exe`);
candidates.add(`${windir}\\Sysnative\\wsl.exe`);
}
candidates.add('wsl.exe');
return [...candidates];
}
async #execNativePs(): Promise<ExecResult> {
await resolveInteractiveShellEnv();
const env = buildEnrichedEnv();
return new Promise((resolve) => {
execFile(
'ps',
['-ax', '-o', 'pid=,ppid=,command='],
{ env, timeout: 3_000, maxBuffer: 2 * 1024 * 1024 },
(error, stdout, stderr) => {
const errorCode =
typeof error === 'object' && error !== null && 'code' in error
? (error as NodeJS.ErrnoException).code
: undefined;
resolve({
exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0,
stdout: String(stdout),
stderr: String(stderr) || (error instanceof Error ? error.message : ''),
});
}
);
});
}
async #resolveNativeTmuxExecutable(env: NodeJS.ProcessEnv): Promise<string> {
const platform =
process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32'
? process.platform
: 'unknown';
const executable = await this.#packageManagerResolver.resolveTmuxBinary(env, platform);
if (!executable) {
throw new Error('tmux executable could not be resolved for the current platform.');
}
return executable;
}
}