mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
Windows Enhancement and Fixes for 0.15.0-RC (#264)
* refactor: consolidate PATH building logic into shared utilities
- Add buildExpandedPath() and buildExpandedEnv() to shared/pathUtils.ts
- Refactor 5 files to use shared PATH functions, eliminating ~170 lines of duplication
- Fix Windows .NET SDK PATH issue by including dotnet installation paths
- Ensure consistent cross-platform PATH handling across CLI agents, detectors, and process managers
Files changed:
- src/shared/pathUtils.ts (added 2 functions)
- src/cli/services/agent-spawner.ts (refactored 3 functions)
- src/main/utils/cliDetection.ts (refactored 1 function)
- src/main/agent-detector.ts (refactored 1 method)
- src/main/process-manager/utils/envBuilder.ts (refactored 1 function)
* refactor: consolidate PATH building logic into shared utilities
- Add buildExpandedPath() and buildExpandedEnv() to shared/pathUtils.ts
- Refactor 5 files to use shared PATH functions, eliminating ~170 lines of duplication
- Fix Windows .NET SDK PATH issue by including dotnet installation paths
- Ensure consistent cross-platform PATH handling across CLI agents, detectors, and process managers
Files changed:
- src/shared/pathUtils.ts (added 2 functions)
- src/cli/services/agent-spawner.ts (refactored 3 functions)
- src/main/utils/cliDetection.ts (refactored 1 function)
- src/main/agent-detector.ts (refactored 1 method)
- src/main/process-manager/utils/envBuilder.ts (refactored 1 function)
* fix(windows): enable PATH access for agent processes
Remove faulty basename rewriting that prevented shell execution from working properly on Windows. Full executable paths are now passed directly to cmd.exe, allowing agents to access PATH and run commands like node -v and dotnet -h.
- Modified process.ts to keep full paths when using shell execution
- Updated ChildProcessSpawner.ts to avoid basename rewriting
- Fixes ENOENT errors when agents spawn on Windows
Resolves issue where agents couldn't execute PATH-based commands.
fix: add missing lint-staged configuration
Add lint-staged configuration to package.json to run prettier and eslint on staged TypeScript files before commits.
* fix(windows): resolve SSH path detection with CRLF line endings
Fix SSH command spawning failure on Windows by properly handling CRLF line endings from the 'where' command. The issue was that result.stdout.trim().split('\n')[0] left trailing \r characters in detected paths, causing ENOENT errors when spawning SSH processes.
Updated detectSshPath() to use split(/\r?\n/) for cross-platform line ending handling
Applied same fix to detect cloudflared and gh paths for consistency
Ensures SSH binary paths are clean of trailing whitespace/carriage returns
Resolves "spawn C:\Windows\System32\OpenSSH\ssh.exe\r ENOENT" errors when using SSH remote agents on Windows.
* fix: resolve SSH remote execution issues with stream-json and slash commands
- Fix SSH remote execution failing with stream-json input by detecting
--input-format stream-json and sending prompts via stdin instead of
command line arguments, preventing shell interpretation of markdown
content (fixes GitHub issue #262)
- Add sendPromptViaStdin flag to ProcessConfig interface for explicit
stream-json mode detection
- Implement proper image support in buildStreamJsonMessage for Claude
Code stream-json format, parsing data URLs and including images as
base64 content in the message
- Add file existence check in discoverSlashCommands to prevent ENOENT
errors when agent paths are invalid
- Simplify ChildProcessSpawner stdin handling to ensure images are
always included in stream-json messages sent via stdin
- Update stream-json message format to use proper {"type": "user_message",
"content": [...]} structure with text and image content arrays
This commit is contained in:
@@ -305,5 +305,11 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx}": [
|
||||
"prettier --write",
|
||||
"eslint --fix"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AgentResult> {
|
||||
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<AgentResult> {
|
||||
return new Promise((resolve) => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
PATH: getExpandedPath(),
|
||||
};
|
||||
const env = buildExpandedEnv();
|
||||
|
||||
const args = [...CODEX_ARGS];
|
||||
args.push('-C', cwd);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, any>,
|
||||
customEnvVars?: Record<string, string>,
|
||||
|
||||
@@ -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<string> {
|
||||
console.log(`[GroupChat:Debug] ========== SPAWNING MODERATOR ==========`);
|
||||
console.log(`[GroupChat:Debug] Chat ID: ${chat.id}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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}".
|
||||
|
||||
@@ -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<string, string> | 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<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)
|
||||
// ========================================================================
|
||||
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)
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, string>,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<boolean> {
|
||||
@@ -80,7 +32,9 @@ export async function isCloudflaredInstalled(): Promise<boolean> {
|
||||
|
||||
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<boolean> {
|
||||
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<string | null> {
|
||||
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;
|
||||
|
||||
@@ -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<string, string>): 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user