mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
Key changes: - Accept main's fix for context usage calculation (returns null for accumulated multi-tool turn values instead of capping at 100%) - Adopt main's refactored structure: - agent-detector.ts → agents/detector.ts + definitions.ts + capabilities.ts - stats-db.ts → stats/*.ts modules - agent-session-storage types → agents/index.ts - Port factory-droid agent to new agents/definitions.ts structure - Remove obsolete shared/contextUsage.ts (logic now in renderer/utils) - Update all import paths to reference new module locations - Preserve all RC features: Symphony, File Preview Tabs, TabNaming, etc. The context window fix is critical: main's approach correctly handles when Claude Code reports accumulated token values from multi-tool turns by returning null, causing the UI to preserve the last valid percentage. RC's approach masked this by capping at 100%, hiding the issue.
689 lines
27 KiB
TypeScript
689 lines
27 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 { 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 { buildSshCommand } 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,
|
|
}),
|
|
});
|
|
|
|
// 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 useHereDocForOpenCode = false;
|
|
|
|
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, otherwise fall back to COMSPEC/cmd.exe.
|
|
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) {
|
|
// Use COMSPEC if available, otherwise default to cmd.exe
|
|
shellToUse = process.env.ComSpec || 'cmd.exe';
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
let shouldSendPromptViaStdin = false;
|
|
let shouldSendPromptViaStdinRaw = false;
|
|
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 - wrap the command for remote execution
|
|
sshRemoteUsed = sshResult.config;
|
|
|
|
// ALWAYS use stdin for SSH remote execution when there's a prompt.
|
|
// Embedding prompts in the command line causes shell escaping nightmares:
|
|
// - Multiple layers of quote escaping (local spawn, SSH, remote zsh, bash -c)
|
|
// - Embedded newlines in prompts break zsh parsing (e.g., "zsh:35: parse error")
|
|
// - Special characters like quotes, $, !, etc. need complex escaping
|
|
// Using stdin with --input-format stream-json completely bypasses all these issues.
|
|
const hasStreamJsonInput =
|
|
finalArgs.includes('--input-format') && finalArgs.includes('stream-json');
|
|
const agentSupportsStreamJson = agent?.capabilities.supportsStreamJsonInput ?? false;
|
|
let sshArgs = finalArgs;
|
|
if (config.prompt && agentSupportsStreamJson) {
|
|
// Agent supports stream-json - always use stdin for prompts
|
|
if (!hasStreamJsonInput) {
|
|
sshArgs = [...finalArgs, '--input-format', 'stream-json'];
|
|
}
|
|
shouldSendPromptViaStdin = true;
|
|
logger.info(`Using stdin for prompt in SSH remote execution`, LOG_CONTEXT, {
|
|
sessionId: config.sessionId,
|
|
promptLength: config.prompt?.length,
|
|
reason: 'ssh-stdin-for-reliability',
|
|
hasStreamJsonInput,
|
|
});
|
|
} else if (config.prompt && !agentSupportsStreamJson) {
|
|
// Agent doesn't support stream-json - use alternative methods
|
|
if (config.toolType === 'opencode') {
|
|
// OpenCode: mark for here document processing (will be handled after remoteCommand is set)
|
|
useHereDocForOpenCode = true;
|
|
} else {
|
|
// Other agents: send via stdin as raw text
|
|
shouldSendPromptViaStdinRaw = true;
|
|
}
|
|
}
|
|
//
|
|
// Determine the command to run on the remote host:
|
|
// 1. If user set a session-specific custom path, use that (they configured it for the remote)
|
|
// 2. Otherwise, use the agent's binaryName (e.g., 'codex', 'claude') and let
|
|
// the remote shell's PATH resolve it. This avoids using local paths like
|
|
// '/opt/homebrew/bin/codex' which don't exist on the remote host.
|
|
let remoteCommand = config.sessionCustomPath || agent?.binaryName || config.command;
|
|
|
|
// Handle OpenCode here document for large prompts
|
|
if (useHereDocForOpenCode && config.prompt) {
|
|
// OpenCode: use here document to avoid command line limits
|
|
// Escape single quotes in the prompt for bash here document
|
|
const escapedPrompt = config.prompt.replace(/'/g, "'\\''");
|
|
// Construct: cat << 'EOF' | opencode run --format json\nlong prompt here\nEOF
|
|
const hereDocCommand = `cat << 'EOF' | ${remoteCommand} ${sshArgs.join(' ')}\n${escapedPrompt}\nEOF`;
|
|
sshArgs = []; // Clear args since they're now in the here doc command
|
|
remoteCommand = hereDocCommand; // Update to use here document
|
|
logger.info(
|
|
`Using here document for large OpenCode prompt to avoid command line limits`,
|
|
LOG_CONTEXT,
|
|
{
|
|
sessionId: config.sessionId,
|
|
promptLength: config.prompt?.length,
|
|
commandLength: hereDocCommand.length,
|
|
}
|
|
);
|
|
}
|
|
// Decide whether we'll send input via stdin to the remote command
|
|
const useStdin = sshArgs.includes('--input-format') && sshArgs.includes('stream-json');
|
|
|
|
const sshCommand = await buildSshCommand(sshResult.config, {
|
|
command: remoteCommand,
|
|
args: sshArgs,
|
|
// Use the cwd from config - this is the project directory on the remote
|
|
cwd: config.cwd,
|
|
// Pass custom environment variables to the remote command
|
|
env: effectiveCustomEnvVars,
|
|
// Explicitly indicate whether stdin will be used so ssh-command-builder
|
|
// can avoid forcing a TTY for stream-json modes.
|
|
useStdin,
|
|
});
|
|
|
|
commandToSpawn = sshCommand.command;
|
|
argsToSpawn = sshCommand.args;
|
|
|
|
// For SSH, env vars are passed in the remote command string, not locally
|
|
customEnvVarsToPass = undefined;
|
|
|
|
// On Windows, use PowerShell for SSH commands to avoid cmd.exe's 8191 character limit
|
|
// PowerShell supports up to 32,767 characters, which is needed for large prompts
|
|
if (isWindows) {
|
|
useShell = true;
|
|
shellToUse = 'powershell.exe';
|
|
logger.info(
|
|
`Using PowerShell for SSH command on Windows to support long command lines`,
|
|
LOG_CONTEXT,
|
|
{
|
|
sessionId: config.sessionId,
|
|
commandLength: sshCommand.args.join(' ').length,
|
|
}
|
|
);
|
|
}
|
|
|
|
// Detailed debug logging to diagnose SSH command execution issues
|
|
logger.debug(`SSH command details for debugging`, LOG_CONTEXT, {
|
|
sessionId: config.sessionId,
|
|
toolType: config.toolType,
|
|
sshBinary: sshCommand.command,
|
|
sshArgsCount: sshCommand.args.length,
|
|
sshArgsArray: sshCommand.args,
|
|
// Show the last arg which contains the wrapped remote command
|
|
remoteCommandString: sshCommand.args[sshCommand.args.length - 1],
|
|
// Show the agent command that will execute remotely
|
|
agentBinary: remoteCommand,
|
|
agentArgs: sshArgs,
|
|
agentCwd: config.cwd,
|
|
// Full invocation for copy-paste debugging
|
|
fullSshInvocation: `${sshCommand.command} ${sshCommand.args
|
|
.map((arg) => (arg.includes(' ') ? `'${arg}'` : arg))
|
|
.join(' ')}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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 command itself
|
|
// 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)
|
|
// and env vars are passed via the remote command string
|
|
requiresPty: sshRemoteUsed ? false : agent?.requiresPty,
|
|
// When using SSH with small prompts, the prompt was already added to sshArgs above
|
|
// For large prompts or stream-json input, pass it to ProcessManager so it can send via stdin
|
|
prompt:
|
|
sshRemoteUsed && config.prompt && shouldSendPromptViaStdin
|
|
? config.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 remote command string, 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
|
|
// For SSH with stream-json input, send prompt via stdin instead of command line
|
|
sendPromptViaStdin: shouldSendPromptViaStdin ? true : undefined,
|
|
sendPromptViaStdinRaw: shouldSendPromptViaStdinRaw ? true : undefined,
|
|
// 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,
|
|
});
|
|
|
|
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 });
|
|
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
|
|
);
|
|
}
|
|
)
|
|
);
|
|
}
|