Files
Maestro/src/main/ipc/handlers/process.ts
Pedram Amini e283cd985a Merge pull request #306 from chr1syy/fix-windows-probing
Windows enhancements and fixes
2026-02-05 15:36:05 -06:00

635 lines
24 KiB
TypeScript

import { ipcMain, BrowserWindow } from 'electron';
import Store from 'electron-store';
import * as os from 'os';
import { ProcessManager } from '../../process-manager';
import { AgentDetector } from '../../agents';
import { logger } from '../../utils/logger';
import { addBreadcrumb } from '../../utils/sentry';
import { isWebContentsAvailable } from '../../utils/safe-send';
import {
buildAgentArgs,
applyAgentConfigOverrides,
getContextWindowValue,
} from '../../utils/agent-args';
import {
withIpcErrorLogging,
requireProcessManager,
requireDependency,
CreateHandlerOptions,
} from '../../utils/ipcHandler';
import { getSshRemoteConfig, createSshRemoteStoreAdapter } from '../../utils/ssh-remote-resolver';
import { buildSshCommandWithStdin } from '../../utils/ssh-command-builder';
import { buildExpandedEnv } from '../../../shared/pathUtils';
import type { SshRemoteConfig } from '../../../shared/types';
import { powerManager } from '../../power-manager';
import { MaestroSettings } from './persistence';
const LOG_CONTEXT = '[ProcessManager]';
/**
* Helper to create handler options with consistent context
*/
const handlerOpts = (
operation: string,
extra?: Partial<CreateHandlerOptions>
): Pick<CreateHandlerOptions, 'context' | 'operation'> => ({
context: LOG_CONTEXT,
operation,
...extra,
});
/**
* Interface for agent configuration store data
*/
interface AgentConfigsData {
configs: Record<string, Record<string, any>>;
}
/**
* Dependencies required for process handler registration
*/
export interface ProcessHandlerDependencies {
getProcessManager: () => ProcessManager | null;
getAgentDetector: () => AgentDetector | null;
agentConfigsStore: Store<AgentConfigsData>;
settingsStore: Store<MaestroSettings>;
getMainWindow: () => BrowserWindow | null;
sessionsStore: Store<{ sessions: any[] }>;
}
/**
* Register all Process-related IPC handlers.
*
* These handlers manage process lifecycle operations:
* - spawn: Start a new process for a session
* - write: Send input to a process
* - interrupt: Send SIGINT to a process
* - kill: Terminate a process
* - resize: Resize PTY dimensions
* - getActiveProcesses: List all running processes
* - runCommand: Execute a single command and capture output
*/
export function registerProcessHandlers(deps: ProcessHandlerDependencies): void {
const { getProcessManager, getAgentDetector, agentConfigsStore, settingsStore, getMainWindow } =
deps;
// Spawn a new process for a session
// Supports agent-specific argument builders for batch mode, JSON output, resume, read-only mode, YOLO mode
ipcMain.handle(
'process:spawn',
withIpcErrorLogging(
handlerOpts('spawn'),
async (config: {
sessionId: string;
toolType: string;
cwd: string;
command: string;
args: string[];
prompt?: string;
shell?: string;
images?: string[]; // Base64 data URLs for images
// Agent-specific spawn options (used to build args via agent config)
agentSessionId?: string; // For session resume
readOnlyMode?: boolean; // For read-only/plan mode
modelId?: string; // For model selection
yoloMode?: boolean; // For YOLO/full-access mode (bypasses confirmations)
// Per-session overrides (take precedence over agent-level config)
sessionCustomPath?: string; // Session-specific custom path
sessionCustomArgs?: string; // Session-specific custom args
sessionCustomEnvVars?: Record<string, string>; // Session-specific env vars
sessionCustomModel?: string; // Session-specific model selection
sessionCustomContextWindow?: number; // Session-specific context window size
// Per-session SSH remote config (takes precedence over agent-level SSH config)
sessionSshRemoteConfig?: {
enabled: boolean;
remoteId: string | null;
workingDirOverride?: string;
};
// Stats tracking options
querySource?: 'user' | 'auto'; // Whether this query is user-initiated or from Auto Run
tabId?: string; // Tab ID for multi-tab tracking
}) => {
const processManager = requireProcessManager(getProcessManager);
const agentDetector = requireDependency(getAgentDetector, 'Agent detector');
// Get agent definition to access config options and argument builders
const agent = await agentDetector.getAgent(config.toolType);
// Use INFO level on Windows for better visibility in logs
const isWindows = process.platform === 'win32';
const logFn = isWindows ? logger.info.bind(logger) : logger.debug.bind(logger);
logFn(`Spawn config received`, LOG_CONTEXT, {
platform: process.platform,
configToolType: config.toolType,
configCommand: config.command,
agentId: agent?.id,
agentCommand: agent?.command,
agentPath: agent?.path,
agentPathExtension: agent?.path ? require('path').extname(agent.path) : 'none',
hasAgentSessionId: !!config.agentSessionId,
hasPrompt: !!config.prompt,
promptLength: config.prompt?.length,
// On Windows, show prompt preview to help debug truncation issues
promptPreview:
config.prompt && isWindows
? {
first50: config.prompt.substring(0, 50),
last50: config.prompt.substring(Math.max(0, config.prompt.length - 50)),
containsHash: config.prompt.includes('#'),
containsNewline: config.prompt.includes('\n'),
}
: undefined,
// SSH remote config logging
hasSessionSshRemoteConfig: !!config.sessionSshRemoteConfig,
sessionSshRemoteConfig: config.sessionSshRemoteConfig
? {
enabled: config.sessionSshRemoteConfig.enabled,
remoteId: config.sessionSshRemoteConfig.remoteId,
hasWorkingDirOverride: !!config.sessionSshRemoteConfig.workingDirOverride,
}
: null,
});
let finalArgs = buildAgentArgs(agent, {
baseArgs: config.args,
prompt: config.prompt,
cwd: config.cwd,
readOnlyMode: config.readOnlyMode,
modelId: config.modelId,
yoloMode: config.yoloMode,
agentSessionId: config.agentSessionId,
});
// ========================================================================
// Apply agent config options and session overrides
// Session-level overrides take precedence over agent-level config
// ========================================================================
const allConfigs = agentConfigsStore.get('configs', {});
const agentConfigValues = allConfigs[config.toolType] || {};
const configResolution = applyAgentConfigOverrides(agent, finalArgs, {
agentConfigValues,
sessionCustomModel: config.sessionCustomModel,
sessionCustomArgs: config.sessionCustomArgs,
sessionCustomEnvVars: config.sessionCustomEnvVars,
});
finalArgs = configResolution.args;
if (configResolution.modelSource === 'session' && config.sessionCustomModel) {
logger.debug(`Using session-level model for ${config.toolType}`, LOG_CONTEXT, {
model: config.sessionCustomModel,
});
}
if (configResolution.customArgsSource !== 'none') {
logger.debug(
`Appending custom args for ${config.toolType} (${configResolution.customArgsSource}-level)`,
LOG_CONTEXT
);
}
const effectiveCustomEnvVars = configResolution.effectiveCustomEnvVars;
if (configResolution.customEnvSource !== 'none' && effectiveCustomEnvVars) {
logger.debug(
`Custom env vars configured for ${config.toolType} (${configResolution.customEnvSource}-level)`,
LOG_CONTEXT,
{ keys: Object.keys(effectiveCustomEnvVars) }
);
}
// If no shell is specified and this is a terminal session, use the default shell from settings
// For terminal sessions, we also load custom shell path, args, and env vars
let shellToUse =
config.shell ||
(config.toolType === 'terminal' ? settingsStore.get('defaultShell', 'zsh') : undefined);
let shellArgsStr: string | undefined;
let shellEnvVars: Record<string, string> | undefined;
if (config.toolType === 'terminal') {
// Custom shell path overrides the detected/selected shell path
const customShellPath = settingsStore.get('customShellPath', '');
if (customShellPath && customShellPath.trim()) {
shellToUse = customShellPath.trim();
logger.debug('Using custom shell path for terminal', LOG_CONTEXT, { customShellPath });
}
// Load additional shell args and env vars
shellArgsStr = settingsStore.get('shellArgs', '');
shellEnvVars = settingsStore.get('shellEnvVars', {}) as Record<string, string>;
}
// Extract session ID from args for logging (supports both --resume and --session flags)
const resumeArgIndex = finalArgs.indexOf('--resume');
const sessionArgIndex = finalArgs.indexOf('--session');
const agentSessionId =
resumeArgIndex !== -1
? finalArgs[resumeArgIndex + 1]
: sessionArgIndex !== -1
? finalArgs[sessionArgIndex + 1]
: config.agentSessionId;
logger.info(`Spawning process: ${config.command}`, LOG_CONTEXT, {
sessionId: config.sessionId,
toolType: config.toolType,
cwd: config.cwd,
command: config.command,
fullCommand: `${config.command} ${finalArgs.join(' ')}`,
args: finalArgs,
requiresPty: agent?.requiresPty || false,
shell: shellToUse,
...(agentSessionId && { agentSessionId }),
...(config.readOnlyMode && { readOnlyMode: true }),
...(config.yoloMode && { yoloMode: true }),
...(config.modelId && { modelId: config.modelId }),
...(config.prompt && {
prompt:
config.prompt.length > 500 ? config.prompt.substring(0, 500) + '...' : config.prompt,
}),
});
// Add breadcrumb for crash diagnostics (MAESTRO-5A/4Y)
await addBreadcrumb('agent', `Spawn: ${config.toolType}`, {
sessionId: config.sessionId,
toolType: config.toolType,
command: config.command,
hasPrompt: !!config.prompt,
});
// Get contextWindow: session-level override takes priority over agent-level config
// Falls back to the agent's configOptions default (e.g., 400000 for Codex, 128000 for OpenCode)
const contextWindow = getContextWindowValue(
agent,
agentConfigValues,
config.sessionCustomContextWindow
);
// ========================================================================
// Command Resolution: Apply session-level custom path override if set
// This allows users to override the detected agent path per-session
//
// NEW: Always use shell execution for agent processes on Windows (except SSH),
// so PATH and other environment variables are available. This ensures cross-platform
// compatibility and correct agent behavior.
// ========================================================================
let commandToSpawn = config.sessionCustomPath || config.command;
let argsToSpawn = finalArgs;
let useShell = false;
let sshRemoteUsed: SshRemoteConfig | null = null;
let customEnvVarsToPass: Record<string, string> | undefined = effectiveCustomEnvVars;
let sshStdinScript: string | undefined;
if (config.sessionCustomPath) {
logger.debug(`Using session-level custom path for ${config.toolType}`, LOG_CONTEXT, {
customPath: config.sessionCustomPath,
originalCommand: config.command,
});
}
// On Windows (except SSH), always use shell execution for agents
if (isWindows && !config.sessionSshRemoteConfig?.enabled) {
useShell = true;
// Use expanded environment with custom env vars to ensure PATH includes all binary locations
const expandedEnv = buildExpandedEnv(customEnvVarsToPass);
// Filter out undefined values to match Record<string, string> type
customEnvVarsToPass = Object.fromEntries(
Object.entries(expandedEnv).filter(([_, value]) => value !== undefined)
) as Record<string, string>;
// Determine an explicit shell to use when forcing shell execution on Windows.
// Prefer a user-configured custom shell path, then PowerShell, then ComSpec/cmd.exe.
// PowerShell is preferred over cmd.exe for better script handling and to avoid cmd.exe limits.
const customShellPath = settingsStore.get('customShellPath', '') as string;
if (customShellPath && customShellPath.trim()) {
shellToUse = customShellPath.trim();
logger.debug('Using custom shell path for forced agent shell on Windows', LOG_CONTEXT, {
customShellPath: shellToUse,
});
} else if (!shellToUse) {
// Try PowerShell if available (common on modern Windows)
// If not, fall back to ComSpec/cmd.exe
// PowerShell handles shell scripts better and avoids cmd.exe command line length limits
const powerShellPath = process.env.PSHOME
? `${process.env.PSHOME}\\powershell.exe`
: 'powershell';
shellToUse = powerShellPath;
logger.debug(
'Using PowerShell for agent execution on Windows (shell script support)',
LOG_CONTEXT,
{
shellPath: shellToUse,
}
);
}
logger.info(`Forcing shell execution for agent on Windows for PATH access`, LOG_CONTEXT, {
agentId: agent?.id,
command: commandToSpawn,
args: argsToSpawn,
shell: shellToUse,
});
}
// ========================================================================
// SSH Remote Execution: Detect and wrap command for remote execution
// Terminal sessions are always local (they need PTY for shell interaction)
// ========================================================================
// Only consider SSH remote for non-terminal AI agent sessions
// SSH is session-level ONLY - no agent-level or global defaults
// Log SSH evaluation on Windows for debugging
if (isWindows) {
logger.info(`Evaluating SSH remote config`, LOG_CONTEXT, {
toolType: config.toolType,
isTerminal: config.toolType === 'terminal',
hasSessionSshRemoteConfig: !!config.sessionSshRemoteConfig,
sshEnabled: config.sessionSshRemoteConfig?.enabled,
willUseSsh: config.toolType !== 'terminal' && config.sessionSshRemoteConfig?.enabled,
});
}
if (config.toolType !== 'terminal' && config.sessionSshRemoteConfig?.enabled) {
// Session-level SSH config provided - resolve and use it
logger.info(`Using session-level SSH config`, LOG_CONTEXT, {
sessionId: config.sessionId,
enabled: config.sessionSshRemoteConfig.enabled,
remoteId: config.sessionSshRemoteConfig.remoteId,
});
// Resolve effective SSH remote configuration
const sshStoreAdapter = createSshRemoteStoreAdapter(settingsStore);
const sshResult = getSshRemoteConfig(sshStoreAdapter, {
sessionSshConfig: config.sessionSshRemoteConfig,
});
if (sshResult.config) {
// SSH remote is configured - use stdin-based execution
// This completely bypasses shell escaping issues by sending the script via stdin
sshRemoteUsed = sshResult.config;
// Determine the command to run on the remote host
const remoteCommand = config.sessionCustomPath || agent?.binaryName || config.command;
// Build the SSH command with stdin script
// The script contains PATH setup, cd, env vars, and the actual command
// This eliminates all shell escaping issues
//
// IMPORTANT: ALL agent prompts are passed via stdin passthrough for SSH.
// Benefits:
// - Avoids CLI argument length limits (128KB-2MB depending on OS)
// - No shell escaping needed - prompt is never parsed by any shell
// - Works with any prompt content (quotes, newlines, special chars)
// - Simpler code - no heredoc or delimiter collision detection
//
// How it works: bash reads the script, `exec` replaces bash with the agent,
// and the agent reads the remaining stdin (the prompt) directly.
const stdinInput = config.prompt;
const sshCommand = await buildSshCommandWithStdin(sshResult.config, {
command: remoteCommand,
args: finalArgs,
cwd: config.cwd,
env: effectiveCustomEnvVars,
// prompt is not passed as CLI arg - it goes via stdinInput
stdinInput,
});
commandToSpawn = sshCommand.command;
argsToSpawn = sshCommand.args;
sshStdinScript = sshCommand.stdinScript;
// For SSH, env vars are passed in the stdin script, not locally
customEnvVarsToPass = undefined;
logger.info(`SSH command built with stdin passthrough`, LOG_CONTEXT, {
sessionId: config.sessionId,
toolType: config.toolType,
sshBinary: sshCommand.command,
sshArgsCount: sshCommand.args.length,
remoteCommand,
remoteCwd: config.cwd,
promptLength: config.prompt?.length,
stdinScriptLength: sshCommand.stdinScript?.length,
});
}
}
// Debug logging for shell configuration
logger.info(`Shell configuration before spawn`, LOG_CONTEXT, {
sessionId: config.sessionId,
useShell,
shellToUse,
isWindows,
isSshCommand: !!sshRemoteUsed,
});
const result = processManager.spawn({
...config,
command: commandToSpawn,
args: argsToSpawn,
// When using SSH, use user's home directory as local cwd
// The remote working directory is embedded in the SSH stdin script
// This fixes ENOENT errors when session.cwd is a remote-only path
cwd: sshRemoteUsed ? os.homedir() : config.cwd,
// When using SSH, disable PTY (SSH provides its own terminal handling)
requiresPty: sshRemoteUsed ? false : agent?.requiresPty,
// For SSH, prompt is included in the stdin script, not passed separately
// For local execution, pass prompt as normal
prompt: sshRemoteUsed ? undefined : config.prompt,
shell: shellToUse,
runInShell: useShell,
shellArgs: shellArgsStr, // Shell-specific CLI args (for terminal sessions)
shellEnvVars: shellEnvVars, // Shell-specific env vars (for terminal sessions)
contextWindow, // Pass configured context window to process manager
// When using SSH, env vars are passed in the stdin script, not locally
customEnvVars: customEnvVarsToPass,
imageArgs: agent?.imageArgs, // Function to build image CLI args (for Codex, OpenCode)
promptArgs: agent?.promptArgs, // Function to build prompt args (e.g., ['-p', prompt] for OpenCode)
noPromptSeparator: agent?.noPromptSeparator, // Some agents don't support '--' before prompt
// Stats tracking: use cwd as projectPath if not explicitly provided
projectPath: config.cwd,
// SSH remote context (for SSH-specific error messages)
sshRemoteId: sshRemoteUsed?.id,
sshRemoteHost: sshRemoteUsed?.host,
// SSH stdin script - the entire command is sent via stdin to /bin/bash on remote
sshStdinScript,
});
logger.info(`Process spawned successfully`, LOG_CONTEXT, {
sessionId: config.sessionId,
pid: result.pid,
...(sshRemoteUsed && {
sshRemoteId: sshRemoteUsed.id,
sshRemoteName: sshRemoteUsed.name,
}),
});
// Add power block reason for AI sessions (not terminals)
// This prevents system sleep while AI is processing
if (config.toolType !== 'terminal') {
powerManager.addBlockReason(`session:${config.sessionId}`);
}
// Emit SSH remote status event for renderer to update session state
// This is emitted for all spawns (sshRemote will be null for local execution)
const mainWindow = getMainWindow();
if (isWebContentsAvailable(mainWindow)) {
const sshRemoteInfo = sshRemoteUsed
? {
id: sshRemoteUsed.id,
name: sshRemoteUsed.name,
host: sshRemoteUsed.host,
}
: null;
mainWindow.webContents.send('process:ssh-remote', config.sessionId, sshRemoteInfo);
}
// Return spawn result with SSH remote info if used
return {
...result,
sshRemote: sshRemoteUsed
? {
id: sshRemoteUsed.id,
name: sshRemoteUsed.name,
host: sshRemoteUsed.host,
}
: undefined,
};
}
)
);
// Write data to a process
ipcMain.handle(
'process:write',
withIpcErrorLogging(handlerOpts('write'), async (sessionId: string, data: string) => {
const processManager = requireProcessManager(getProcessManager);
logger.debug(`Writing to process: ${sessionId}`, LOG_CONTEXT, {
sessionId,
dataLength: data.length,
});
return processManager.write(sessionId, data);
})
);
// Send SIGINT to a process
ipcMain.handle(
'process:interrupt',
withIpcErrorLogging(handlerOpts('interrupt'), async (sessionId: string) => {
const processManager = requireProcessManager(getProcessManager);
logger.info(`Interrupting process: ${sessionId}`, LOG_CONTEXT, { sessionId });
return processManager.interrupt(sessionId);
})
);
// Kill a process
ipcMain.handle(
'process:kill',
withIpcErrorLogging(handlerOpts('kill'), async (sessionId: string) => {
const processManager = requireProcessManager(getProcessManager);
logger.info(`Killing process: ${sessionId}`, LOG_CONTEXT, { sessionId });
// Add breadcrumb for crash diagnostics (MAESTRO-5A/4Y)
await addBreadcrumb('agent', `Kill: ${sessionId}`, { sessionId });
return processManager.kill(sessionId);
})
);
// Resize PTY dimensions
ipcMain.handle(
'process:resize',
withIpcErrorLogging(
handlerOpts('resize'),
async (sessionId: string, cols: number, rows: number) => {
const processManager = requireProcessManager(getProcessManager);
return processManager.resize(sessionId, cols, rows);
}
)
);
// Get all active processes managed by the ProcessManager
ipcMain.handle(
'process:getActiveProcesses',
withIpcErrorLogging(handlerOpts('getActiveProcesses'), async () => {
const processManager = requireProcessManager(getProcessManager);
const processes = processManager.getAll();
// Return serializable process info (exclude non-serializable PTY/child process objects)
return processes.map((p) => ({
sessionId: p.sessionId,
toolType: p.toolType,
pid: p.pid,
cwd: p.cwd,
isTerminal: p.isTerminal,
isBatchMode: p.isBatchMode || false,
startTime: p.startTime,
command: p.command,
args: p.args,
}));
})
);
// Run a single command and capture only stdout/stderr (no PTY echo/prompts)
// Supports SSH remote execution when sessionSshRemoteConfig is provided
ipcMain.handle(
'process:runCommand',
withIpcErrorLogging(
handlerOpts('runCommand'),
async (config: {
sessionId: string;
command: string;
cwd: string;
shell?: string;
// Per-session SSH remote config (same as process:spawn)
sessionSshRemoteConfig?: {
enabled: boolean;
remoteId: string | null;
workingDirOverride?: string;
};
}) => {
const processManager = requireProcessManager(getProcessManager);
// Get the shell from settings if not provided
// Custom shell path takes precedence over the selected shell ID
let shell = config.shell || settingsStore.get('defaultShell', 'zsh');
const customShellPath = settingsStore.get('customShellPath', '');
if (customShellPath && customShellPath.trim()) {
shell = customShellPath.trim();
}
// Get shell env vars for passing to runCommand
const shellEnvVars = settingsStore.get('shellEnvVars', {}) as Record<string, string>;
// ========================================================================
// SSH Remote Execution: Resolve SSH config if provided
// ========================================================================
let sshRemoteConfig: SshRemoteConfig | null = null;
if (config.sessionSshRemoteConfig?.enabled && config.sessionSshRemoteConfig?.remoteId) {
const sshStoreAdapter = createSshRemoteStoreAdapter(settingsStore);
const sshResult = getSshRemoteConfig(sshStoreAdapter, {
sessionSshConfig: config.sessionSshRemoteConfig,
});
if (sshResult.config) {
sshRemoteConfig = sshResult.config;
logger.info(`Terminal command will execute via SSH`, LOG_CONTEXT, {
sessionId: config.sessionId,
remoteName: sshResult.config.name,
remoteHost: sshResult.config.host,
source: sshResult.source,
});
}
}
logger.debug(`Running command: ${config.command}`, LOG_CONTEXT, {
sessionId: config.sessionId,
cwd: config.cwd,
shell,
hasCustomEnvVars: Object.keys(shellEnvVars).length > 0,
sshRemote: sshRemoteConfig?.name || null,
});
return processManager.runCommand(
config.sessionId,
config.command,
config.cwd,
shell,
shellEnvVars,
sshRemoteConfig
);
}
)
);
}