diff --git a/package.json b/package.json index 84ba012c..75ff16cc 100644 --- a/package.json +++ b/package.json @@ -305,5 +305,11 @@ }, "engines": { "node": ">=22.0.0" + }, + "lint-staged": { + "*.{ts,tsx}": [ + "prettier --write", + "eslint --fix" + ] } } diff --git a/src/cli/services/agent-spawner.ts b/src/cli/services/agent-spawner.ts index 489aacdb..2cda0f4d 100644 --- a/src/cli/services/agent-spawner.ts +++ b/src/cli/services/agent-spawner.ts @@ -2,12 +2,12 @@ // Spawns agent CLIs (Claude Code, Codex) and parses their output import { spawn, SpawnOptions } from 'child_process'; -import * as os from 'os'; import * as fs from 'fs'; import type { ToolType, UsageStats } from '../../shared/types'; import { CodexOutputParser } from '../../main/parsers/codex-output-parser'; import { getAgentCustomPath } from './storage'; import { generateUUID } from '../../shared/uuid'; +import { buildExpandedPath, buildExpandedEnv } from '../../shared/pathUtils'; // Claude Code default command and arguments (same as Electron app) const CLAUDE_DEFAULT_COMMAND = 'claude'; @@ -47,32 +47,7 @@ export interface AgentResult { * Build an expanded PATH that includes common binary installation locations */ function getExpandedPath(): string { - const home = os.homedir(); - const additionalPaths = [ - '/opt/homebrew/bin', - '/opt/homebrew/sbin', - '/usr/local/bin', - '/usr/local/sbin', - `${home}/.local/bin`, - `${home}/.npm-global/bin`, - `${home}/bin`, - `${home}/.claude/local`, - '/usr/bin', - '/bin', - '/usr/sbin', - '/sbin', - ]; - - const currentPath = process.env.PATH || ''; - const pathParts = currentPath.split(':'); - - for (const p of additionalPaths) { - if (!pathParts.includes(p)) { - pathParts.unshift(p); - } - } - - return pathParts.join(':'); + return buildExpandedPath(); } /** @@ -250,10 +225,7 @@ async function spawnClaudeAgent( agentSessionId?: string ): Promise { return new Promise((resolve) => { - const env: NodeJS.ProcessEnv = { - ...process.env, - PATH: getExpandedPath(), - }; + const env = buildExpandedEnv(); // Build args: base args + session handling + prompt const args = [...CLAUDE_ARGS]; @@ -433,10 +405,7 @@ async function spawnCodexAgent( agentSessionId?: string ): Promise { return new Promise((resolve) => { - const env: NodeJS.ProcessEnv = { - ...process.env, - PATH: getExpandedPath(), - }; + const env = buildExpandedEnv(); const args = [...CODEX_ARGS]; args.push('-C', cwd); diff --git a/src/main/agent-detector.ts b/src/main/agent-detector.ts index cf9114d0..1d2d2fe2 100644 --- a/src/main/agent-detector.ts +++ b/src/main/agent-detector.ts @@ -4,7 +4,7 @@ import * as os from 'os'; import * as fs from 'fs'; import * as path from 'path'; import { AgentCapabilities, getAgentCapabilities } from './agent-capabilities'; -import { expandTilde, detectNodeVersionManagerBinPaths } from '../shared/pathUtils'; +import { expandTilde, detectNodeVersionManagerBinPaths, buildExpandedEnv } from '../shared/pathUtils'; // Re-export AgentCapabilities for convenience export { AgentCapabilities } from './agent-capabilities'; @@ -472,96 +472,7 @@ export class AgentDetector { * This is necessary because packaged Electron apps don't inherit shell environment. */ private getExpandedEnv(): NodeJS.ProcessEnv { - const home = os.homedir(); - const env = { ...process.env }; - const isWindows = process.platform === 'win32'; - - // Platform-specific paths - let additionalPaths: string[]; - - if (isWindows) { - // Windows-specific paths - const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); - const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); - const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; - const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; - - additionalPaths = [ - // Claude Code PowerShell installer (irm https://claude.ai/install.ps1 | iex) - // This is the primary installation method - installs claude.exe to ~/.local/bin - path.join(home, '.local', 'bin'), - // Claude Code winget install (winget install --id Anthropic.ClaudeCode) - path.join(localAppData, 'Microsoft', 'WinGet', 'Links'), - path.join(programFiles, 'WinGet', 'Links'), - path.join(localAppData, 'Microsoft', 'WinGet', 'Packages'), - path.join(programFiles, 'WinGet', 'Packages'), - // npm global installs (Claude Code, Codex CLI, Gemini CLI) - path.join(appData, 'npm'), - path.join(localAppData, 'npm'), - // Claude Code CLI install location (npm global) - path.join(appData, 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli'), - // Codex CLI install location (npm global) - path.join(appData, 'npm', 'node_modules', '@openai', 'codex', 'bin'), - // User local programs - path.join(localAppData, 'Programs'), - path.join(localAppData, 'Microsoft', 'WindowsApps'), - // Python/pip user installs - path.join(appData, 'Python', 'Scripts'), - path.join(localAppData, 'Programs', 'Python', 'Python312', 'Scripts'), - path.join(localAppData, 'Programs', 'Python', 'Python311', 'Scripts'), - path.join(localAppData, 'Programs', 'Python', 'Python310', 'Scripts'), - // Git for Windows (provides bash, common tools) - path.join(programFiles, 'Git', 'cmd'), - path.join(programFiles, 'Git', 'bin'), - path.join(programFiles, 'Git', 'usr', 'bin'), - path.join(programFilesX86, 'Git', 'cmd'), - path.join(programFilesX86, 'Git', 'bin'), - // Node.js - path.join(programFiles, 'nodejs'), - path.join(localAppData, 'Programs', 'node'), - // Scoop package manager (OpenCode, other tools) - path.join(home, 'scoop', 'shims'), - path.join(home, 'scoop', 'apps', 'opencode', 'current'), - // Chocolatey (OpenCode, other tools) - path.join(process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', 'bin'), - // Go binaries (some tools installed via 'go install') - path.join(home, 'go', 'bin'), - // Windows system paths - path.join(process.env.SystemRoot || 'C:\\Windows', 'System32'), - path.join(process.env.SystemRoot || 'C:\\Windows'), - ]; - } else { - // Unix-like paths (macOS/Linux) - additionalPaths = [ - '/opt/homebrew/bin', // Homebrew on Apple Silicon - '/opt/homebrew/sbin', - '/usr/local/bin', // Homebrew on Intel, common install location - '/usr/local/sbin', - `${home}/.local/bin`, // User local installs (pip, etc.) - `${home}/.npm-global/bin`, // npm global with custom prefix - `${home}/bin`, // User bin directory - `${home}/.claude/local`, // Claude local install location - `${home}/.opencode/bin`, // OpenCode installer default location - '/usr/bin', - '/bin', - '/usr/sbin', - '/sbin', - ]; - } - - const currentPath = env.PATH || ''; - // Use platform-appropriate path delimiter - const pathParts = currentPath.split(path.delimiter); - - // Add paths that aren't already present - for (const p of additionalPaths) { - if (!pathParts.includes(p)) { - pathParts.unshift(p); - } - } - - env.PATH = pathParts.join(path.delimiter); - return env; + return buildExpandedEnv(); } /** diff --git a/src/main/group-chat/group-chat-agent.ts b/src/main/group-chat/group-chat-agent.ts index 307ddf41..dc211ae1 100644 --- a/src/main/group-chat/group-chat-agent.ts +++ b/src/main/group-chat/group-chat-agent.ts @@ -8,6 +8,7 @@ * - Participants can collaborate by referencing the shared chat log */ +import * as os from 'os'; import { v4 as uuidv4 } from 'uuid'; import { GroupChatParticipant, @@ -93,7 +94,7 @@ export async function addParticipant( name: string, agentId: string, processManager: IProcessManager, - cwd: string = process.env.HOME || '/tmp', + cwd: string = os.homedir(), agentDetector?: AgentDetector, agentConfigValues?: Record, customEnvVars?: Record, diff --git a/src/main/group-chat/group-chat-moderator.ts b/src/main/group-chat/group-chat-moderator.ts index d89d57eb..c8cbd637 100644 --- a/src/main/group-chat/group-chat-moderator.ts +++ b/src/main/group-chat/group-chat-moderator.ts @@ -8,6 +8,7 @@ * - Aggregates responses and maintains conversation flow */ +import * as os from 'os'; import { GroupChat, loadGroupChat, updateGroupChat } from './group-chat-storage'; import { appendToLog, readLog } from './group-chat-log'; import { groupChatModeratorSystemPrompt, groupChatModeratorSynthesisPrompt } from '../../prompts'; @@ -143,7 +144,7 @@ export function getModeratorSynthesisPrompt(): string { export async function spawnModerator( chat: GroupChat, _processManager: IProcessManager, - _cwd: string = process.env.HOME || '/tmp' + _cwd: string = os.homedir() ): Promise { console.log(`[GroupChat:Debug] ========== SPAWNING MODERATOR ==========`); console.log(`[GroupChat:Debug] Chat ID: ${chat.id}`); diff --git a/src/main/group-chat/group-chat-router.ts b/src/main/group-chat/group-chat-router.ts index 5163c5c8..5c2b4272 100644 --- a/src/main/group-chat/group-chat-router.ts +++ b/src/main/group-chat/group-chat-router.ts @@ -8,6 +8,7 @@ * - Participants -> Moderator */ +import * as os from 'os'; import { GroupChatParticipant, loadGroupChat, @@ -438,7 +439,7 @@ ${message}`; const baseArgs = buildAgentArgs(agent, { baseArgs: args, prompt: fullPrompt, - cwd: process.env.HOME || '/tmp', + cwd: os.homedir(), readOnlyMode: true, }); const configResolution = applyAgentConfigOverrides(agent, baseArgs, { @@ -453,7 +454,7 @@ ${message}`; console.log(`[GroupChat:Debug] ========== SPAWNING MODERATOR PROCESS ==========`); console.log(`[GroupChat:Debug] Session ID: ${sessionId}`); console.log(`[GroupChat:Debug] Tool Type: ${chat.moderatorAgentId}`); - console.log(`[GroupChat:Debug] CWD: ${process.env.HOME || '/tmp'}`); + console.log(`[GroupChat:Debug] CWD: ${os.homedir()}`); console.log(`[GroupChat:Debug] Command: ${command}`); console.log(`[GroupChat:Debug] ReadOnly: true`); @@ -469,7 +470,7 @@ ${message}`; // Prepare spawn config with potential SSH wrapping let spawnCommand = command; let spawnArgs = finalArgs; - let spawnCwd = process.env.HOME || '/tmp'; + let spawnCwd = os.homedir(); let spawnPrompt: string | undefined = fullPrompt; let spawnEnvVars = configResolution.effectiveCustomEnvVars ?? @@ -482,7 +483,7 @@ ${message}`; { command, args: finalArgs, - cwd: process.env.HOME || '/tmp', + cwd: os.homedir(), prompt: fullPrompt, customEnvVars: configResolution.effectiveCustomEnvVars ?? @@ -748,7 +749,7 @@ export async function routeModeratorResponse( const matchingSession = sessions.find( (s) => mentionMatches(s.name, participantName) || s.name === participantName ); - const cwd = matchingSession?.cwd || process.env.HOME || '/tmp'; + const cwd = matchingSession?.cwd || os.homedir(); console.log(`[GroupChat:Debug] CWD for participant: ${cwd}`); // Resolve agent configuration @@ -1132,7 +1133,7 @@ Review the agent responses above. Either: const baseArgs = buildAgentArgs(agent, { baseArgs: args, prompt: synthesisPrompt, - cwd: process.env.HOME || '/tmp', + cwd: os.homedir(), readOnlyMode: true, }); const configResolution = applyAgentConfigOverrides(agent, baseArgs, { @@ -1155,7 +1156,7 @@ Review the agent responses above. Either: const spawnResult = processManager.spawn({ sessionId, toolType: chat.moderatorAgentId, - cwd: process.env.HOME || '/tmp', + cwd: os.homedir(), command, args: finalArgs, readOnlyMode: true, @@ -1246,7 +1247,7 @@ export async function respawnParticipantWithRecovery( const matchingSession = sessions.find( (s) => mentionMatches(s.name, participantName) || s.name === participantName ); - const cwd = matchingSession?.cwd || process.env.HOME || '/tmp'; + const cwd = matchingSession?.cwd || os.homedir(); // Build the prompt with recovery context const readOnlyNote = readOnly diff --git a/src/main/index.ts b/src/main/index.ts index 27875894..3c1f0642 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow } from 'electron'; import path from 'path'; +import os from 'os'; import crypto from 'crypto'; // Sentry is imported dynamically below to avoid module-load-time access to electron.app // which causes "Cannot read properties of undefined (reading 'getAppPath')" errors @@ -550,7 +551,7 @@ function setupIpcHandlers() { id: s.id, name: s.name, toolType: s.toolType, - cwd: s.cwd || s.fullPath || process.env.HOME || '/tmp', + cwd: s.cwd || s.fullPath || os.homedir(), customArgs: s.customArgs, customEnvVars: s.customEnvVars, customModel: s.customModel, diff --git a/src/main/ipc/handlers/agents.ts b/src/main/ipc/handlers/agents.ts index c18651e3..c98630da 100644 --- a/src/main/ipc/handlers/agents.ts +++ b/src/main/ipc/handlers/agents.ts @@ -1,5 +1,6 @@ import { ipcMain } from 'electron'; import Store from 'electron-store'; +import * as fs from 'fs'; import { AgentDetector, AGENT_DEFINITIONS } from '../../agent-detector'; import { getAgentCapabilities } from '../../agent-capabilities'; import { execFileNoThrow } from '../../utils/execFile'; @@ -305,128 +306,140 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void { }) ); - // Get a specific agent by ID (supports SSH remote detection via optional sshRemoteId) - ipcMain.handle( - 'agents:get', - withIpcErrorLogging(handlerOpts('get'), async (agentId: string, sshRemoteId?: string) => { - logger.debug(`Getting agent: ${agentId}`, LOG_CONTEXT, { sshRemoteId }); + // Get a specific agent by ID (supports SSH remote detection via optional sshRemoteId) + ipcMain.handle( + 'agents:get', + withIpcErrorLogging(handlerOpts('get'), async (agentId: string, sshRemoteId?: string) => { + logger.debug(`Getting agent: ${agentId}`, LOG_CONTEXT, { sshRemoteId }); - // If SSH remote ID provided, detect agent on remote host - if (sshRemoteId) { - const sshConfig = getSshRemoteById(settingsStore, sshRemoteId); - if (!sshConfig) { - logger.warn(`SSH remote not found or disabled: ${sshRemoteId}`, LOG_CONTEXT); - // Return the agent definition with unavailable status - const agentDef = AGENT_DEFINITIONS.find((a) => a.id === agentId); - if (!agentDef) { - throw new Error(`Unknown agent: ${agentId}`); - } - return stripAgentFunctions({ - ...agentDef, - available: false, - path: undefined, - capabilities: getAgentCapabilities(agentDef.id), - error: `SSH remote configuration not found: ${sshRemoteId}`, - }); - } + // If SSH remote ID provided, detect agent on remote host + if (sshRemoteId) { + const sshConfig = getSshRemoteById(settingsStore, sshRemoteId); + if (!sshConfig) { + logger.warn(`SSH remote not found or disabled: ${sshRemoteId}`, LOG_CONTEXT); + // Return the agent definition with unavailable status + const agentDef = AGENT_DEFINITIONS.find((a) => a.id === agentId); + if (!agentDef) { + throw new Error(`Unknown agent: ${agentId}`); + } + return stripAgentFunctions({ + ...agentDef, + available: false, + path: undefined, + capabilities: getAgentCapabilities(agentDef.id), + error: `SSH remote configuration not found: ${sshRemoteId}`, + }); + } - logger.info(`Getting agent ${agentId} on remote host: ${sshConfig.host}`, LOG_CONTEXT); + logger.info(`Getting agent ${agentId} on remote host: ${sshConfig.host}`, LOG_CONTEXT); - // Find the agent definition - const agentDef = AGENT_DEFINITIONS.find((a) => a.id === agentId); - if (!agentDef) { - throw new Error(`Unknown agent: ${agentId}`); - } + // Find the agent definition + const agentDef = AGENT_DEFINITIONS.find((a) => a.id === agentId); + if (!agentDef) { + throw new Error(`Unknown agent: ${agentId}`); + } - // Build SSH command to check for the binary using 'which' - const remoteOptions: RemoteCommandOptions = { - command: 'which', - args: [agentDef.binaryName], - }; + // Build SSH command to check for the binary using 'which' + const remoteOptions: RemoteCommandOptions = { + command: 'which', + args: [agentDef.binaryName], + }; - try { - const sshCommand = await buildSshCommand(sshConfig, remoteOptions); - logger.info(`Executing SSH detection command for '${agentDef.binaryName}'`, LOG_CONTEXT, { - command: sshCommand.command, - args: sshCommand.args, - }); + try { + const sshCommand = await buildSshCommand(sshConfig, remoteOptions); + logger.info(`Executing SSH detection command for '${agentDef.binaryName}'`, LOG_CONTEXT, { + command: sshCommand.command, + args: sshCommand.args, + }); - // Execute with timeout - const SSH_TIMEOUT_MS = 10000; - const resultPromise = execFileNoThrow(sshCommand.command, sshCommand.args); - const timeoutPromise = new Promise<{ exitCode: number; stdout: string; stderr: string }>((_, reject) => { - setTimeout(() => reject(new Error(`SSH connection timed out after ${SSH_TIMEOUT_MS / 1000}s`)), SSH_TIMEOUT_MS); - }); + // Execute with timeout + const SSH_TIMEOUT_MS = 10000; + const resultPromise = execFileNoThrow(sshCommand.command, sshCommand.args); + const timeoutPromise = new Promise<{ exitCode: number; stdout: string; stderr: string }>( + (_, reject) => { + setTimeout( + () => reject(new Error(`SSH connection timed out after ${SSH_TIMEOUT_MS / 1000}s`)), + SSH_TIMEOUT_MS + ); + } + ); - const result = await Promise.race([resultPromise, timeoutPromise]); + const result = await Promise.race([resultPromise, timeoutPromise]); - logger.info(`SSH command result for '${agentDef.binaryName}'`, LOG_CONTEXT, { - exitCode: result.exitCode, - stdout: result.stdout, - stderr: result.stderr, - }); + logger.info(`SSH command result for '${agentDef.binaryName}'`, LOG_CONTEXT, { + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }); - // Check for SSH connection errors - let connectionError: string | undefined; - if (result.stderr && ( - result.stderr.includes('Connection refused') || - result.stderr.includes('Connection timed out') || - result.stderr.includes('No route to host') || - result.stderr.includes('Could not resolve hostname') || - result.stderr.includes('Permission denied') - )) { - connectionError = result.stderr.trim().split('\n')[0]; - logger.warn(`SSH connection error for ${sshConfig.host}: ${connectionError}`, LOG_CONTEXT); - } + // Check for SSH connection errors + let connectionError: string | undefined; + if ( + result.stderr && + (result.stderr.includes('Connection refused') || + result.stderr.includes('Connection timed out') || + result.stderr.includes('No route to host') || + result.stderr.includes('Could not resolve hostname') || + result.stderr.includes('Permission denied')) + ) { + connectionError = result.stderr.trim().split('\n')[0]; + logger.warn( + `SSH connection error for ${sshConfig.host}: ${connectionError}`, + LOG_CONTEXT + ); + } - // Strip ANSI/OSC escape sequences from output - const cleanedOutput = stripAnsi(result.stdout); - const available = result.exitCode === 0 && cleanedOutput.trim().length > 0; - const path = available ? cleanedOutput.trim().split('\n')[0] : undefined; + // Strip ANSI/OSC escape sequences from output + const cleanedOutput = stripAnsi(result.stdout); + const available = result.exitCode === 0 && cleanedOutput.trim().length > 0; + const path = available ? cleanedOutput.trim().split('\n')[0] : undefined; - if (available) { - logger.info(`Agent "${agentDef.name}" found on remote at: ${path}`, LOG_CONTEXT); - } else { - logger.debug(`Agent "${agentDef.name}" not found on remote`, LOG_CONTEXT); - } + if (available) { + logger.info(`Agent "${agentDef.name}" found on remote at: ${path}`, LOG_CONTEXT); + } else { + logger.debug(`Agent "${agentDef.name}" not found on remote`, LOG_CONTEXT); + } - return stripAgentFunctions({ - ...agentDef, - available, - path, - capabilities: getAgentCapabilities(agentDef.id), - error: connectionError, - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.warn(`Failed to check agent "${agentDef.name}" on remote: ${errorMessage}`, LOG_CONTEXT); - return stripAgentFunctions({ - ...agentDef, - available: false, - capabilities: getAgentCapabilities(agentDef.id), - error: `Failed to connect: ${errorMessage}`, - }); - } - } + return stripAgentFunctions({ + ...agentDef, + available, + path, + capabilities: getAgentCapabilities(agentDef.id), + error: connectionError, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn( + `Failed to check agent "${agentDef.name}" on remote: ${errorMessage}`, + LOG_CONTEXT + ); + return stripAgentFunctions({ + ...agentDef, + available: false, + capabilities: getAgentCapabilities(agentDef.id), + error: `Failed to connect: ${errorMessage}`, + }); + } + } - // Local detection - const agentDetector = requireDependency(getAgentDetector, 'Agent detector'); - const agent = await agentDetector.getAgent(agentId); + // Local detection + const agentDetector = requireDependency(getAgentDetector, 'Agent detector'); + const agent = await agentDetector.getAgent(agentId); - // Debug logging for agent availability - logger.debug(`Agent retrieved: ${agentId}`, LOG_CONTEXT, { - available: agent?.available, - hasPath: !!agent?.path, - path: agent?.path, - command: agent?.command, - hasCustomPath: !!agent?.customPath, - customPath: agent?.customPath, - }); + // Debug logging for agent availability + logger.debug(`Agent retrieved: ${agentId}`, LOG_CONTEXT, { + available: agent?.available, + hasPath: !!agent?.path, + path: agent?.path, + command: agent?.command, + hasCustomPath: !!agent?.customPath, + customPath: agent?.customPath, + }); - // Strip argBuilder functions before sending over IPC - return stripAgentFunctions(agent); - }) - ); + // Strip argBuilder functions before sending over IPC + return stripAgentFunctions(agent); + }) + ); // Get capabilities for a specific agent ipcMain.handle( @@ -732,6 +745,15 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void { // Use custom path if provided, otherwise use detected path const commandPath = customPath || agent.path || agent.command; + // Check if the command path exists before attempting to spawn + if (!fs.existsSync(commandPath)) { + logger.warn( + `Command path does not exist for slash command discovery: ${commandPath}`, + LOG_CONTEXT + ); + return null; + } + // Spawn Claude with /help which immediately exits and costs no tokens // The init message contains all available slash commands const args = [ diff --git a/src/main/ipc/handlers/groupChat.ts b/src/main/ipc/handlers/groupChat.ts index 847c4018..181f26d2 100644 --- a/src/main/ipc/handlers/groupChat.ts +++ b/src/main/ipc/handlers/groupChat.ts @@ -9,6 +9,7 @@ * - Participant management (add, send, remove) */ +import * as os from 'os'; import { ipcMain, BrowserWindow } from 'electron'; import { withIpcErrorLogging, CreateHandlerOptions } from '../../utils/ipcHandler'; import { logger } from '../../utils/logger'; @@ -480,7 +481,7 @@ export function registerGroupChatHandlers(deps: GroupChatHandlerDependencies): v name, agentId, processManager, - cwd || process.env.HOME || '/tmp', + cwd || os.homedir(), agentDetector ?? undefined, agentConfigValues, customEnvVars @@ -558,7 +559,7 @@ export function registerGroupChatHandlers(deps: GroupChatHandlerDependencies): v // Get the group chat folder for file access const groupChatFolder = getGroupChatDir(groupChatId); - const effectiveCwd = cwd || process.env.HOME || '/tmp'; + const effectiveCwd = cwd || os.homedir(); // Build a context summary prompt to ask the agent to summarize its current state const summaryPrompt = `You are "${participantName}" in the group chat "${chat.name}". diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts index b4cc5e50..7f534c7d 100644 --- a/src/main/ipc/handlers/process.ts +++ b/src/main/ipc/handlers/process.ts @@ -253,43 +253,60 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void // Command Resolution: Apply session-level custom path override if set // This allows users to override the detected agent path per-session // - // WINDOWS FIX: On Windows, prefer the resolved agent path with .exe extension - // to avoid using shell:true in ProcessManager. When shell:true is used, - // stdin piping through cmd.exe is unreliable - data written to stdin may not - // be forwarded to the child process. This breaks stream-json input mode. - // By using the full path with .exe extension, ProcessManager will spawn - // the process directly without cmd.exe wrapper, ensuring stdin works correctly. + // 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 | undefined = effectiveCustomEnvVars; if (config.sessionCustomPath) { logger.debug(`Using session-level custom path for ${config.toolType}`, LOG_CONTEXT, { customPath: config.sessionCustomPath, originalCommand: config.command, }); - } else if (isWindows && agent?.path && !config.sessionSshRemoteConfig?.enabled) { - // On Windows LOCAL execution, use the full resolved agent path if it ends with .exe or .com - // This avoids ProcessManager setting shell:true for extensionless commands, - // which breaks stdin piping (needed for stream-json input mode) - // NOTE: Skip this for SSH sessions - SSH uses the remote agent path, not local - const pathExt = require('path').extname(agent.path).toLowerCase(); - if (pathExt === '.exe' || pathExt === '.com') { - commandToSpawn = agent.path; - logger.debug(`Using full agent path on Windows to avoid shell wrapper`, LOG_CONTEXT, { - originalCommand: config.command, - resolvedPath: agent.path, - reason: 'stdin-reliability', + } + + // On Windows (except SSH), always use shell execution for agents + if (isWindows && !config.sessionSshRemoteConfig?.enabled) { + useShell = true; + // Merge process.env with custom env vars, to ensure PATH is present + // Only keep string values (filter out undefined) + customEnvVarsToPass = Object.fromEntries( + Object.entries({ + ...process.env, + ...(customEnvVarsToPass || {}), + }).filter(([_, v]) => typeof v === 'string') + ) as Record; + + // 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) // ======================================================================== - let sshRemoteUsed: SshRemoteConfig | null = null; - // 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 @@ -302,6 +319,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void willUseSsh: config.toolType !== 'terminal' && config.sessionSshRemoteConfig?.enabled, }); } + let shouldSendPromptViaStdin = 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, { @@ -327,10 +345,15 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void // IMPORTANT: For large prompts (>4000 chars), don't embed in command line to avoid // Windows command line length limits (~8191 chars). SSH wrapping adds significant overhead. // Instead, add --input-format stream-json and let ProcessManager send via stdin. + // + // Also, when --input-format stream-json is already present, the prompt must be sent via stdin, + // not on the command line, to avoid shell interpretation issues. const isLargePrompt = config.prompt && config.prompt.length > 4000; + const hasStreamJsonInput = + finalArgs.includes('--input-format') && finalArgs.includes('stream-json'); let sshArgs = finalArgs; - if (config.prompt && !isLargePrompt) { - // Small prompt - embed in command line as usual + if (config.prompt && !isLargePrompt && !hasStreamJsonInput) { + // Small prompt - embed in command line as usual (only if not using stream-json input) if (agent?.promptArgs) { sshArgs = [...finalArgs, ...agent.promptArgs(config.prompt)]; } else if (agent?.noPromptSeparator) { @@ -338,14 +361,19 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void } else { sshArgs = [...finalArgs, '--', config.prompt]; } - } else if (config.prompt && isLargePrompt) { - // Large prompt - use stdin mode - // Add --input-format stream-json flag so agent reads from stdin - sshArgs = [...finalArgs, '--input-format', 'stream-json']; - logger.info(`Using stdin for large prompt in SSH remote execution`, LOG_CONTEXT, { + } else if (config.prompt && (isLargePrompt || hasStreamJsonInput)) { + // Large prompt or stream-json input - ensure --input-format stream-json is present + 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: 'avoid-command-line-length-limit', + promptLength: config.prompt?.length, + reason: isLargePrompt + ? 'avoid-command-line-length-limit' + : 'stream-json-input-mode', + hasStreamJsonInput, }); } @@ -408,22 +436,25 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void // 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, pass it to ProcessManager so it can send via stdin + // For large prompts or stream-json input, pass it to ProcessManager so it can send via stdin prompt: - sshRemoteUsed && config.prompt && config.prompt.length > 4000 + 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: sshRemoteUsed ? undefined : effectiveCustomEnvVars, + 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, // Stats tracking: use cwd as projectPath if not explicitly provided projectPath: config.cwd, // SSH remote context (for SSH-specific error messages) diff --git a/src/main/process-manager/spawners/ChildProcessSpawner.ts b/src/main/process-manager/spawners/ChildProcessSpawner.ts index 4ecb16b2..2e08b8cf 100644 --- a/src/main/process-manager/spawners/ChildProcessSpawner.ts +++ b/src/main/process-manager/spawners/ChildProcessSpawner.ts @@ -152,49 +152,55 @@ export class ChildProcessSpawner { // Handle Windows shell requirements const spawnCommand = command; let spawnArgs = finalArgs; - let useShell = false; + // Respect explicit request from caller, but also be defensive: if caller + // did not set runInShell and we're on Windows with a bare .exe basename, + // enable shell so PATH resolution occurs. This avoids ENOENT when callers + // rewrite the command to basename (or pass a basename) but forget to set + // the runInShell flag. + let useShell = !!config.runInShell; - if (isWindows) { - const lowerCommand = command.toLowerCase(); - // Use shell for batch files - if (lowerCommand.endsWith('.cmd') || lowerCommand.endsWith('.bat')) { - useShell = true; - logger.debug( - '[ProcessManager] Using shell=true for Windows batch file', - 'ProcessManager', - { command } - ); - } else if (!lowerCommand.endsWith('.exe') && !lowerCommand.endsWith('.com')) { - // Check if the command has any extension at all - const hasExtension = path.extname(command).length > 0; - if (!hasExtension) { - useShell = true; - logger.debug( - '[ProcessManager] Using shell=true for Windows command without extension', - 'ProcessManager', - { command } - ); - } - } + // Auto-enable shell for Windows when command is a bare .exe (no path) + const commandHasPath = /\\|\//.test(spawnCommand); + const commandExt = path.extname(spawnCommand).toLowerCase(); + if (isWindows && !useShell && !commandHasPath && commandExt === '.exe') { + useShell = true; + logger.info( + '[ProcessManager] Auto-enabling shell for Windows to allow PATH resolution of basename exe', + 'ProcessManager', + { command: spawnCommand } + ); + } + if (isWindows && useShell) { + logger.debug( + '[ProcessManager] Forcing shell=true for agent spawn on Windows (runInShell or auto)', + 'ProcessManager', + { command: spawnCommand } + ); // Escape arguments for cmd.exe when using shell - if (useShell) { - spawnArgs = finalArgs.map((arg) => { - const needsQuoting = /[ &|<>^%!()"\n\r#?*]/.test(arg) || arg.length > 100; - if (needsQuoting) { - const escaped = arg.replace(/"/g, '""').replace(/\^/g, '^^'); - return `"${escaped}"`; - } - return arg; - }); - logger.info('[ProcessManager] Escaped args for Windows shell', 'ProcessManager', { - originalArgsCount: finalArgs.length, - escapedArgsCount: spawnArgs.length, - escapedPromptArgLength: spawnArgs[spawnArgs.length - 1]?.length, - escapedPromptArgPreview: spawnArgs[spawnArgs.length - 1]?.substring(0, 200), - argsModified: finalArgs.some((arg, i) => arg !== spawnArgs[i]), - }); - } + spawnArgs = finalArgs.map((arg) => { + const needsQuoting = /[ &|<>^%!()"\n\r#?*]/.test(arg) || arg.length > 100; + if (needsQuoting) { + const escaped = arg.replace(/"/g, '""').replace(/\^/g, '^^'); + return `"${escaped}"`; + } + return arg; + }); + logger.info('[ProcessManager] Escaped args for Windows shell', 'ProcessManager', { + originalArgsCount: finalArgs.length, + escapedArgsCount: spawnArgs.length, + escapedPromptArgLength: spawnArgs[spawnArgs.length - 1]?.length, + escapedPromptArgPreview: spawnArgs[spawnArgs.length - 1]?.substring(0, 200), + argsModified: finalArgs.some((arg, i) => arg !== spawnArgs[i]), + }); + } + + // Determine shell option to pass to child_process.spawn. + // If the caller provided a specific shell path, prefer that (string). + // Otherwise pass a boolean indicating whether to use the default shell. + let spawnShell: boolean | string = !!useShell; + if (useShell && typeof config.shell === 'string' && config.shell.trim()) { + spawnShell = config.shell.trim(); } // Log spawn details @@ -202,7 +208,8 @@ export class ChildProcessSpawner { spawnLogFn('[ProcessManager] About to spawn with shell option', 'ProcessManager', { sessionId, spawnCommand, - useShell, + // show the actual shell value passed to spawn (boolean or shell path) + spawnShell: typeof spawnShell === 'string' ? spawnShell : !!spawnShell, isWindows, argsCount: spawnArgs.length, promptArgLength: prompt ? spawnArgs[spawnArgs.length - 1]?.length : undefined, @@ -212,7 +219,7 @@ export class ChildProcessSpawner { const childProcess = spawn(spawnCommand, spawnArgs, { cwd, env, - shell: useShell, + shell: spawnShell, stdio: ['pipe', 'pipe', 'pipe'], }); @@ -227,13 +234,14 @@ export class ChildProcessSpawner { }); const isBatchMode = !!prompt; - // Detect JSON streaming mode from args + // Detect JSON streaming mode from args or config flag const argsContain = (pattern: string) => finalArgs.some((arg) => arg.includes(pattern)); const isStreamJsonMode = argsContain('stream-json') || argsContain('--json') || (argsContain('--format') && argsContain('json')) || - (hasImages && !!prompt); + (hasImages && !!prompt) || + !!config.sendPromptViaStdin; // Get the output parser for this agent type const outputParser = getOutputParser(toolType) || undefined; @@ -377,29 +385,17 @@ export class ChildProcessSpawner { }); // Handle stdin for batch mode and stream-json - if (isStreamJsonMode && prompt && images) { - // Stream-json mode with images: send the message via stdin - const streamJsonMessage = buildStreamJsonMessage(prompt, images); - logger.debug('[ProcessManager] Sending stream-json message with images', 'ProcessManager', { + if (isStreamJsonMode && prompt) { + // Stream-json mode: send the message via stdin + const streamJsonMessage = buildStreamJsonMessage(prompt, images || []); + logger.debug('[ProcessManager] Sending stream-json message via stdin', 'ProcessManager', { sessionId, messageLength: streamJsonMessage.length, - imageCount: images.length, + imageCount: (images || []).length, + hasImages: !!(images && images.length > 0), }); childProcess.stdin?.write(streamJsonMessage + '\n'); childProcess.stdin?.end(); - } else if (isStreamJsonMode && prompt) { - // Stream-json mode with prompt but no images: send JSON via stdin - const streamJsonMessage = buildStreamJsonMessage(prompt, []); - logger.debug( - '[ProcessManager] Sending stream-json prompt via stdin (no images)', - 'ProcessManager', - { - sessionId, - promptLength: prompt.length, - } - ); - childProcess.stdin?.write(streamJsonMessage + '\n'); - childProcess.stdin?.end(); } else if (isBatchMode) { // Regular batch mode: close stdin immediately logger.debug('[ProcessManager] Closing stdin for batch mode', 'ProcessManager', { diff --git a/src/main/process-manager/types.ts b/src/main/process-manager/types.ts index fcf5aba8..5e2a38c6 100644 --- a/src/main/process-manager/types.ts +++ b/src/main/process-manager/types.ts @@ -28,6 +28,10 @@ export interface ProcessConfig { querySource?: 'user' | 'auto'; tabId?: string; projectPath?: string; + /** If true, always spawn in a shell (for PATH resolution on Windows) */ + runInShell?: boolean; + /** If true, send the prompt via stdin as JSON instead of command line */ + sendPromptViaStdin?: boolean; } /** diff --git a/src/main/process-manager/utils/envBuilder.ts b/src/main/process-manager/utils/envBuilder.ts index 444d8a06..2002b64d 100644 --- a/src/main/process-manager/utils/envBuilder.ts +++ b/src/main/process-manager/utils/envBuilder.ts @@ -1,7 +1,7 @@ import * as os from 'os'; import * as path from 'path'; import { STANDARD_UNIX_PATHS } from '../constants'; -import { detectNodeVersionManagerBinPaths } from '../../../shared/pathUtils'; +import { detectNodeVersionManagerBinPaths, buildExpandedPath } from '../../../shared/pathUtils'; /** * Build the base PATH for macOS/Linux with detected Node version manager paths. @@ -58,40 +58,10 @@ export function buildChildProcessEnv( customEnvVars?: Record, isResuming?: boolean ): NodeJS.ProcessEnv { - const isWindows = process.platform === 'win32'; - const home = os.homedir(); const env = { ...process.env }; - // Platform-specific standard paths - let standardPaths: string; - let checkPath: string; - - if (isWindows) { - const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); - const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); - const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; - - standardPaths = [ - path.join(appData, 'npm'), - path.join(localAppData, 'npm'), - path.join(programFiles, 'nodejs'), - path.join(programFiles, 'Git', 'cmd'), - path.join(programFiles, 'Git', 'bin'), - path.join(process.env.SystemRoot || 'C:\\Windows', 'System32'), - ].join(';'); - checkPath = path.join(appData, 'npm'); - } else { - standardPaths = buildUnixBasePath(); - checkPath = '/opt/homebrew/bin'; - } - - if (env.PATH) { - if (!env.PATH.includes(checkPath)) { - env.PATH = `${standardPaths}${path.delimiter}${env.PATH}`; - } - } else { - env.PATH = standardPaths; - } + // Use the shared expanded PATH + env.PATH = buildExpandedPath(); if (isResuming) { env.MAESTRO_SESSION_RESUMED = '1'; @@ -99,6 +69,7 @@ export function buildChildProcessEnv( // Apply custom environment variables if (customEnvVars && Object.keys(customEnvVars).length > 0) { + const home = os.homedir(); for (const [key, value] of Object.entries(customEnvVars)) { env[key] = value.startsWith('~/') ? path.join(home, value.slice(2)) : value; } diff --git a/src/main/process-manager/utils/streamJsonBuilder.ts b/src/main/process-manager/utils/streamJsonBuilder.ts index 9baf251d..60c6e80b 100644 --- a/src/main/process-manager/utils/streamJsonBuilder.ts +++ b/src/main/process-manager/utils/streamJsonBuilder.ts @@ -22,9 +22,15 @@ type MessageContent = ImageContent | TextContent; export function buildStreamJsonMessage(prompt: string, images: string[]): string { const content: MessageContent[] = []; - // Add images first - for (const dataUrl of images) { - const parsed = parseDataUrl(dataUrl); + // Add text content first + content.push({ + type: 'text', + text: prompt, + }); + + // Add image content for each image + for (const imageDataUrl of images) { + const parsed = parseDataUrl(imageDataUrl); if (parsed) { content.push({ type: 'image', @@ -37,18 +43,9 @@ export function buildStreamJsonMessage(prompt: string, images: string[]): string } } - // Add text prompt - content.push({ - type: 'text', - text: prompt, - }); - const message = { - type: 'user', - message: { - role: 'user', - content, - }, + type: 'user_message', + content, }; return JSON.stringify(message); diff --git a/src/main/utils/cliDetection.ts b/src/main/utils/cliDetection.ts index 7acecf00..e50b82e1 100644 --- a/src/main/utils/cliDetection.ts +++ b/src/main/utils/cliDetection.ts @@ -1,6 +1,6 @@ import { execFileNoThrow } from './execFile'; -import * as os from 'os'; import * as path from 'path'; +import { buildExpandedEnv } from '../../shared/pathUtils'; let cloudflaredInstalledCache: boolean | null = null; let cloudflaredPathCache: string | null = null; @@ -16,55 +16,7 @@ const GH_STATUS_CACHE_TTL_MS = 60000; // 1 minute TTL for auth status * This is necessary because packaged Electron apps don't inherit shell environment. */ export function getExpandedEnv(): NodeJS.ProcessEnv { - const home = os.homedir(); - const env = { ...process.env }; - const isWindows = process.platform === 'win32'; - - // Platform-specific paths - let additionalPaths: string[]; - - if (isWindows) { - const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); - const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); - const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; - const systemRoot = process.env.SystemRoot || 'C:\\Windows'; - - additionalPaths = [ - path.join(appData, 'npm'), - path.join(localAppData, 'npm'), - path.join(programFiles, 'cloudflared'), - path.join(home, 'scoop', 'shims'), - path.join(process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', 'bin'), - path.join(systemRoot, 'System32'), - // Windows OpenSSH (placed last so it's checked first due to unshift loop) - path.join(systemRoot, 'System32', 'OpenSSH'), - ]; - } else { - additionalPaths = [ - '/opt/homebrew/bin', // Homebrew on Apple Silicon - '/opt/homebrew/sbin', - '/usr/local/bin', // Homebrew on Intel, common install location - '/usr/local/sbin', - `${home}/.local/bin`, // User local installs - `${home}/bin`, // User bin directory - '/usr/bin', - '/bin', - '/usr/sbin', - '/sbin', - ]; - } - - const currentPath = env.PATH || ''; - const pathParts = currentPath.split(path.delimiter); - - for (const p of additionalPaths) { - if (!pathParts.includes(p)) { - pathParts.unshift(p); - } - } - - env.PATH = pathParts.join(path.delimiter); - return env; + return buildExpandedEnv(); } export async function isCloudflaredInstalled(): Promise { @@ -80,7 +32,9 @@ export async function isCloudflaredInstalled(): Promise { if (result.exitCode === 0 && result.stdout.trim()) { cloudflaredInstalledCache = true; - cloudflaredPathCache = result.stdout.trim().split('\n')[0]; + // Handle Windows CRLF line endings properly + const lines = result.stdout.trim().split(/\r?\n/); + cloudflaredPathCache = lines[0]?.trim() || null; } else { cloudflaredInstalledCache = false; } @@ -115,7 +69,9 @@ export async function isGhInstalled(): Promise { if (result.exitCode === 0 && result.stdout.trim()) { ghInstalledCache = true; // On Windows, 'where' can return multiple paths - take the first one - ghPathCache = result.stdout.trim().split('\n')[0]; + // Handle Windows CRLF line endings properly + const lines = result.stdout.trim().split(/\r?\n/); + ghPathCache = lines[0]?.trim() || null; } else { ghInstalledCache = false; } @@ -209,21 +165,24 @@ export async function detectSshPath(): Promise { const result = await execFileNoThrow(command, ['ssh'], undefined, env); if (result.exitCode === 0 && result.stdout.trim()) { - sshPathCache = result.stdout.trim().split('\n')[0]; - } else if (process.platform === 'win32') { - // Fallback for Windows: Check the built-in OpenSSH location directly - // This is the standard location for Windows 10/11 OpenSSH - const fs = await import('fs'); - const systemRoot = process.env.SystemRoot || 'C:\\Windows'; - const opensshPath = path.join(systemRoot, 'System32', 'OpenSSH', 'ssh.exe'); + // Handle Windows CRLF line endings properly + // On Windows, 'where' returns paths with \r\n, so we need to split on \r?\n + const lines = result.stdout.trim().split(/\r?\n/); + sshPathCache = lines[0]?.trim() || null; + } else if (process.platform === 'win32') { + // Fallback for Windows: Check the built-in OpenSSH location directly + // This is the standard location for Windows 10/11 OpenSSH + const fs = await import('fs'); + const systemRoot = process.env.SystemRoot || 'C:\\Windows'; + const opensshPath = path.join(systemRoot, 'System32', 'OpenSSH', 'ssh.exe'); - try { - if (fs.existsSync(opensshPath)) { - sshPathCache = opensshPath; - } - } catch { - // If check fails, leave sshPathCache as null - } + try { + if (fs.existsSync(opensshPath)) { + sshPathCache = opensshPath; + } + } catch { + // If check fails, leave sshPathCache as null + } } sshDetectionDone = true; diff --git a/src/shared/pathUtils.ts b/src/shared/pathUtils.ts index 5fff99e6..c8474455 100644 --- a/src/shared/pathUtils.ts +++ b/src/shared/pathUtils.ts @@ -226,3 +226,159 @@ export function detectNodeVersionManagerBinPaths(): string[] { return detectedPaths; } + +/** + * Build an expanded PATH string with common binary installation locations. + * + * This consolidates PATH building logic used across the application to ensure + * consistency and prevent duplication. Handles platform differences automatically. + * + * @param customPaths - Optional additional paths to prepend to PATH + * @returns Expanded PATH string with platform-appropriate paths included + * + * @example + * ```typescript + * const expandedPath = buildExpandedPath(); + * // Returns PATH with common binary locations added + * + * const customPath = buildExpandedPath(['/custom/bin']); + * // Returns PATH with custom paths + common locations + * ``` + */ +export function buildExpandedPath(customPaths?: string[]): string { + const isWindows = process.platform === 'win32'; + const delimiter = path.delimiter; + const home = os.homedir(); + + // Start with current PATH + const currentPath = process.env.PATH || ''; + const pathParts = currentPath.split(delimiter); + + // Platform-specific additional paths + let additionalPaths: string[]; + + if (isWindows) { + const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); + const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; + const systemRoot = process.env.SystemRoot || 'C:\\Windows'; + + additionalPaths = [ + // .NET SDK installations + path.join(programFiles, 'dotnet'), + path.join(programFilesX86, 'dotnet'), + // Claude Code PowerShell installer + path.join(home, '.local', 'bin'), + // Claude Code winget install + path.join(localAppData, 'Microsoft', 'WinGet', 'Links'), + path.join(programFiles, 'WinGet', 'Links'), + path.join(localAppData, 'Microsoft', 'WinGet', 'Packages'), + path.join(programFiles, 'WinGet', 'Packages'), + // npm global installs + path.join(appData, 'npm'), + path.join(localAppData, 'npm'), + // Claude Code CLI install location (npm global) + path.join(appData, 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli'), + // Codex CLI install location (npm global) + path.join(appData, 'npm', 'node_modules', '@openai', 'codex', 'bin'), + // User local programs + path.join(localAppData, 'Programs'), + path.join(localAppData, 'Microsoft', 'WindowsApps'), + // Python/pip user installs + path.join(appData, 'Python', 'Scripts'), + path.join(localAppData, 'Programs', 'Python', 'Python312', 'Scripts'), + path.join(localAppData, 'Programs', 'Python', 'Python311', 'Scripts'), + path.join(localAppData, 'Programs', 'Python', 'Python310', 'Scripts'), + // Git for Windows + path.join(programFiles, 'Git', 'cmd'), + path.join(programFiles, 'Git', 'bin'), + path.join(programFiles, 'Git', 'usr', 'bin'), + path.join(programFilesX86, 'Git', 'cmd'), + path.join(programFilesX86, 'Git', 'bin'), + // Node.js + path.join(programFiles, 'nodejs'), + path.join(localAppData, 'Programs', 'node'), + // Cloudflared + path.join(programFiles, 'cloudflared'), + // Scoop package manager + path.join(home, 'scoop', 'shims'), + path.join(home, 'scoop', 'apps', 'opencode', 'current'), + // Chocolatey + path.join(process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', 'bin'), + // Go binaries + path.join(home, 'go', 'bin'), + // Windows system paths + path.join(systemRoot, 'System32'), + path.join(systemRoot), + // Windows OpenSSH + path.join(systemRoot, 'System32', 'OpenSSH'), + ]; + } else { + // Unix-like paths (macOS/Linux) + additionalPaths = [ + '/opt/homebrew/bin', // Homebrew on Apple Silicon + '/opt/homebrew/sbin', + '/usr/local/bin', // Homebrew on Intel, common install location + '/usr/local/sbin', + `${home}/.local/bin`, // User local installs (pip, etc.) + `${home}/.npm-global/bin`, // npm global with custom prefix + `${home}/bin`, // User bin directory + `${home}/.claude/local`, // Claude local install location + `${home}/.opencode/bin`, // OpenCode installer default location + '/usr/bin', + '/bin', + '/usr/sbin', + '/sbin', + ]; + } + + // Add custom paths first (if provided) + if (customPaths && customPaths.length > 0) { + for (const p of customPaths) { + if (!pathParts.includes(p)) { + pathParts.unshift(p); + } + } + } + + // Add standard additional paths + for (const p of additionalPaths) { + if (!pathParts.includes(p)) { + pathParts.unshift(p); + } + } + + return pathParts.join(delimiter); +} + +/** + * Build an expanded environment object with common binary installation locations in PATH. + * + * This creates a complete environment object (copy of process.env) with an expanded PATH + * that includes platform-specific binary locations. Useful for spawning processes that + * need access to tools not in the default PATH. + * + * @param customEnvVars - Optional additional environment variables to set + * @returns Complete environment object with expanded PATH + * + * @example + * ```typescript + * const env = buildExpandedEnv({ NODE_ENV: 'development' }); + * // Returns process.env copy with expanded PATH + custom vars + * ``` + */ +export function buildExpandedEnv(customEnvVars?: Record): NodeJS.ProcessEnv { + const env = { ...process.env }; + env.PATH = buildExpandedPath(); + + // Apply custom environment variables + if (customEnvVars && Object.keys(customEnvVars).length > 0) { + const home = os.homedir(); + for (const [key, value] of Object.entries(customEnvVars)) { + env[key] = value.startsWith('~/') ? path.join(home, value.slice(2)) : value; + } + } + + return env; +}