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": {
|
"engines": {
|
||||||
"node": ">=22.0.0"
|
"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
|
// Spawns agent CLIs (Claude Code, Codex) and parses their output
|
||||||
|
|
||||||
import { spawn, SpawnOptions } from 'child_process';
|
import { spawn, SpawnOptions } from 'child_process';
|
||||||
import * as os from 'os';
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import type { ToolType, UsageStats } from '../../shared/types';
|
import type { ToolType, UsageStats } from '../../shared/types';
|
||||||
import { CodexOutputParser } from '../../main/parsers/codex-output-parser';
|
import { CodexOutputParser } from '../../main/parsers/codex-output-parser';
|
||||||
import { getAgentCustomPath } from './storage';
|
import { getAgentCustomPath } from './storage';
|
||||||
import { generateUUID } from '../../shared/uuid';
|
import { generateUUID } from '../../shared/uuid';
|
||||||
|
import { buildExpandedPath, buildExpandedEnv } from '../../shared/pathUtils';
|
||||||
|
|
||||||
// Claude Code default command and arguments (same as Electron app)
|
// Claude Code default command and arguments (same as Electron app)
|
||||||
const CLAUDE_DEFAULT_COMMAND = 'claude';
|
const CLAUDE_DEFAULT_COMMAND = 'claude';
|
||||||
@@ -47,32 +47,7 @@ export interface AgentResult {
|
|||||||
* Build an expanded PATH that includes common binary installation locations
|
* Build an expanded PATH that includes common binary installation locations
|
||||||
*/
|
*/
|
||||||
function getExpandedPath(): string {
|
function getExpandedPath(): string {
|
||||||
const home = os.homedir();
|
return buildExpandedPath();
|
||||||
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(':');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -250,10 +225,7 @@ async function spawnClaudeAgent(
|
|||||||
agentSessionId?: string
|
agentSessionId?: string
|
||||||
): Promise<AgentResult> {
|
): Promise<AgentResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env = buildExpandedEnv();
|
||||||
...process.env,
|
|
||||||
PATH: getExpandedPath(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build args: base args + session handling + prompt
|
// Build args: base args + session handling + prompt
|
||||||
const args = [...CLAUDE_ARGS];
|
const args = [...CLAUDE_ARGS];
|
||||||
@@ -433,10 +405,7 @@ async function spawnCodexAgent(
|
|||||||
agentSessionId?: string
|
agentSessionId?: string
|
||||||
): Promise<AgentResult> {
|
): Promise<AgentResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env = buildExpandedEnv();
|
||||||
...process.env,
|
|
||||||
PATH: getExpandedPath(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const args = [...CODEX_ARGS];
|
const args = [...CODEX_ARGS];
|
||||||
args.push('-C', cwd);
|
args.push('-C', cwd);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as os from 'os';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { AgentCapabilities, getAgentCapabilities } from './agent-capabilities';
|
import { AgentCapabilities, getAgentCapabilities } from './agent-capabilities';
|
||||||
import { expandTilde, detectNodeVersionManagerBinPaths } from '../shared/pathUtils';
|
import { expandTilde, detectNodeVersionManagerBinPaths, buildExpandedEnv } from '../shared/pathUtils';
|
||||||
|
|
||||||
// Re-export AgentCapabilities for convenience
|
// Re-export AgentCapabilities for convenience
|
||||||
export { AgentCapabilities } from './agent-capabilities';
|
export { AgentCapabilities } from './agent-capabilities';
|
||||||
@@ -472,96 +472,7 @@ export class AgentDetector {
|
|||||||
* This is necessary because packaged Electron apps don't inherit shell environment.
|
* This is necessary because packaged Electron apps don't inherit shell environment.
|
||||||
*/
|
*/
|
||||||
private getExpandedEnv(): NodeJS.ProcessEnv {
|
private getExpandedEnv(): NodeJS.ProcessEnv {
|
||||||
const home = os.homedir();
|
return buildExpandedEnv();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* - Participants can collaborate by referencing the shared chat log
|
* - Participants can collaborate by referencing the shared chat log
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as os from 'os';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import {
|
import {
|
||||||
GroupChatParticipant,
|
GroupChatParticipant,
|
||||||
@@ -93,7 +94,7 @@ export async function addParticipant(
|
|||||||
name: string,
|
name: string,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
processManager: IProcessManager,
|
processManager: IProcessManager,
|
||||||
cwd: string = process.env.HOME || '/tmp',
|
cwd: string = os.homedir(),
|
||||||
agentDetector?: AgentDetector,
|
agentDetector?: AgentDetector,
|
||||||
agentConfigValues?: Record<string, any>,
|
agentConfigValues?: Record<string, any>,
|
||||||
customEnvVars?: Record<string, string>,
|
customEnvVars?: Record<string, string>,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* - Aggregates responses and maintains conversation flow
|
* - Aggregates responses and maintains conversation flow
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as os from 'os';
|
||||||
import { GroupChat, loadGroupChat, updateGroupChat } from './group-chat-storage';
|
import { GroupChat, loadGroupChat, updateGroupChat } from './group-chat-storage';
|
||||||
import { appendToLog, readLog } from './group-chat-log';
|
import { appendToLog, readLog } from './group-chat-log';
|
||||||
import { groupChatModeratorSystemPrompt, groupChatModeratorSynthesisPrompt } from '../../prompts';
|
import { groupChatModeratorSystemPrompt, groupChatModeratorSynthesisPrompt } from '../../prompts';
|
||||||
@@ -143,7 +144,7 @@ export function getModeratorSynthesisPrompt(): string {
|
|||||||
export async function spawnModerator(
|
export async function spawnModerator(
|
||||||
chat: GroupChat,
|
chat: GroupChat,
|
||||||
_processManager: IProcessManager,
|
_processManager: IProcessManager,
|
||||||
_cwd: string = process.env.HOME || '/tmp'
|
_cwd: string = os.homedir()
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
console.log(`[GroupChat:Debug] ========== SPAWNING MODERATOR ==========`);
|
console.log(`[GroupChat:Debug] ========== SPAWNING MODERATOR ==========`);
|
||||||
console.log(`[GroupChat:Debug] Chat ID: ${chat.id}`);
|
console.log(`[GroupChat:Debug] Chat ID: ${chat.id}`);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* - Participants -> Moderator
|
* - Participants -> Moderator
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as os from 'os';
|
||||||
import {
|
import {
|
||||||
GroupChatParticipant,
|
GroupChatParticipant,
|
||||||
loadGroupChat,
|
loadGroupChat,
|
||||||
@@ -438,7 +439,7 @@ ${message}`;
|
|||||||
const baseArgs = buildAgentArgs(agent, {
|
const baseArgs = buildAgentArgs(agent, {
|
||||||
baseArgs: args,
|
baseArgs: args,
|
||||||
prompt: fullPrompt,
|
prompt: fullPrompt,
|
||||||
cwd: process.env.HOME || '/tmp',
|
cwd: os.homedir(),
|
||||||
readOnlyMode: true,
|
readOnlyMode: true,
|
||||||
});
|
});
|
||||||
const configResolution = applyAgentConfigOverrides(agent, baseArgs, {
|
const configResolution = applyAgentConfigOverrides(agent, baseArgs, {
|
||||||
@@ -453,7 +454,7 @@ ${message}`;
|
|||||||
console.log(`[GroupChat:Debug] ========== SPAWNING MODERATOR PROCESS ==========`);
|
console.log(`[GroupChat:Debug] ========== SPAWNING MODERATOR PROCESS ==========`);
|
||||||
console.log(`[GroupChat:Debug] Session ID: ${sessionId}`);
|
console.log(`[GroupChat:Debug] Session ID: ${sessionId}`);
|
||||||
console.log(`[GroupChat:Debug] Tool Type: ${chat.moderatorAgentId}`);
|
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] Command: ${command}`);
|
||||||
console.log(`[GroupChat:Debug] ReadOnly: true`);
|
console.log(`[GroupChat:Debug] ReadOnly: true`);
|
||||||
|
|
||||||
@@ -469,7 +470,7 @@ ${message}`;
|
|||||||
// Prepare spawn config with potential SSH wrapping
|
// Prepare spawn config with potential SSH wrapping
|
||||||
let spawnCommand = command;
|
let spawnCommand = command;
|
||||||
let spawnArgs = finalArgs;
|
let spawnArgs = finalArgs;
|
||||||
let spawnCwd = process.env.HOME || '/tmp';
|
let spawnCwd = os.homedir();
|
||||||
let spawnPrompt: string | undefined = fullPrompt;
|
let spawnPrompt: string | undefined = fullPrompt;
|
||||||
let spawnEnvVars =
|
let spawnEnvVars =
|
||||||
configResolution.effectiveCustomEnvVars ??
|
configResolution.effectiveCustomEnvVars ??
|
||||||
@@ -482,7 +483,7 @@ ${message}`;
|
|||||||
{
|
{
|
||||||
command,
|
command,
|
||||||
args: finalArgs,
|
args: finalArgs,
|
||||||
cwd: process.env.HOME || '/tmp',
|
cwd: os.homedir(),
|
||||||
prompt: fullPrompt,
|
prompt: fullPrompt,
|
||||||
customEnvVars:
|
customEnvVars:
|
||||||
configResolution.effectiveCustomEnvVars ??
|
configResolution.effectiveCustomEnvVars ??
|
||||||
@@ -748,7 +749,7 @@ export async function routeModeratorResponse(
|
|||||||
const matchingSession = sessions.find(
|
const matchingSession = sessions.find(
|
||||||
(s) => mentionMatches(s.name, participantName) || s.name === participantName
|
(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}`);
|
console.log(`[GroupChat:Debug] CWD for participant: ${cwd}`);
|
||||||
|
|
||||||
// Resolve agent configuration
|
// Resolve agent configuration
|
||||||
@@ -1132,7 +1133,7 @@ Review the agent responses above. Either:
|
|||||||
const baseArgs = buildAgentArgs(agent, {
|
const baseArgs = buildAgentArgs(agent, {
|
||||||
baseArgs: args,
|
baseArgs: args,
|
||||||
prompt: synthesisPrompt,
|
prompt: synthesisPrompt,
|
||||||
cwd: process.env.HOME || '/tmp',
|
cwd: os.homedir(),
|
||||||
readOnlyMode: true,
|
readOnlyMode: true,
|
||||||
});
|
});
|
||||||
const configResolution = applyAgentConfigOverrides(agent, baseArgs, {
|
const configResolution = applyAgentConfigOverrides(agent, baseArgs, {
|
||||||
@@ -1155,7 +1156,7 @@ Review the agent responses above. Either:
|
|||||||
const spawnResult = processManager.spawn({
|
const spawnResult = processManager.spawn({
|
||||||
sessionId,
|
sessionId,
|
||||||
toolType: chat.moderatorAgentId,
|
toolType: chat.moderatorAgentId,
|
||||||
cwd: process.env.HOME || '/tmp',
|
cwd: os.homedir(),
|
||||||
command,
|
command,
|
||||||
args: finalArgs,
|
args: finalArgs,
|
||||||
readOnlyMode: true,
|
readOnlyMode: true,
|
||||||
@@ -1246,7 +1247,7 @@ export async function respawnParticipantWithRecovery(
|
|||||||
const matchingSession = sessions.find(
|
const matchingSession = sessions.find(
|
||||||
(s) => mentionMatches(s.name, participantName) || s.name === participantName
|
(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
|
// Build the prompt with recovery context
|
||||||
const readOnlyNote = readOnly
|
const readOnlyNote = readOnly
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow } from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
// Sentry is imported dynamically below to avoid module-load-time access to electron.app
|
// Sentry is imported dynamically below to avoid module-load-time access to electron.app
|
||||||
// which causes "Cannot read properties of undefined (reading 'getAppPath')" errors
|
// which causes "Cannot read properties of undefined (reading 'getAppPath')" errors
|
||||||
@@ -550,7 +551,7 @@ function setupIpcHandlers() {
|
|||||||
id: s.id,
|
id: s.id,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
toolType: s.toolType,
|
toolType: s.toolType,
|
||||||
cwd: s.cwd || s.fullPath || process.env.HOME || '/tmp',
|
cwd: s.cwd || s.fullPath || os.homedir(),
|
||||||
customArgs: s.customArgs,
|
customArgs: s.customArgs,
|
||||||
customEnvVars: s.customEnvVars,
|
customEnvVars: s.customEnvVars,
|
||||||
customModel: s.customModel,
|
customModel: s.customModel,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ipcMain } from 'electron';
|
import { ipcMain } from 'electron';
|
||||||
import Store from 'electron-store';
|
import Store from 'electron-store';
|
||||||
|
import * as fs from 'fs';
|
||||||
import { AgentDetector, AGENT_DEFINITIONS } from '../../agent-detector';
|
import { AgentDetector, AGENT_DEFINITIONS } from '../../agent-detector';
|
||||||
import { getAgentCapabilities } from '../../agent-capabilities';
|
import { getAgentCapabilities } from '../../agent-capabilities';
|
||||||
import { execFileNoThrow } from '../../utils/execFile';
|
import { execFileNoThrow } from '../../utils/execFile';
|
||||||
@@ -354,9 +355,14 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void {
|
|||||||
// Execute with timeout
|
// Execute with timeout
|
||||||
const SSH_TIMEOUT_MS = 10000;
|
const SSH_TIMEOUT_MS = 10000;
|
||||||
const resultPromise = execFileNoThrow(sshCommand.command, sshCommand.args);
|
const resultPromise = execFileNoThrow(sshCommand.command, sshCommand.args);
|
||||||
const timeoutPromise = new Promise<{ exitCode: number; stdout: string; stderr: string }>((_, reject) => {
|
const timeoutPromise = new Promise<{ exitCode: number; stdout: string; stderr: string }>(
|
||||||
setTimeout(() => reject(new Error(`SSH connection timed out after ${SSH_TIMEOUT_MS / 1000}s`)), SSH_TIMEOUT_MS);
|
(_, 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]);
|
||||||
|
|
||||||
@@ -368,15 +374,19 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void {
|
|||||||
|
|
||||||
// Check for SSH connection errors
|
// Check for SSH connection errors
|
||||||
let connectionError: string | undefined;
|
let connectionError: string | undefined;
|
||||||
if (result.stderr && (
|
if (
|
||||||
result.stderr.includes('Connection refused') ||
|
result.stderr &&
|
||||||
|
(result.stderr.includes('Connection refused') ||
|
||||||
result.stderr.includes('Connection timed out') ||
|
result.stderr.includes('Connection timed out') ||
|
||||||
result.stderr.includes('No route to host') ||
|
result.stderr.includes('No route to host') ||
|
||||||
result.stderr.includes('Could not resolve hostname') ||
|
result.stderr.includes('Could not resolve hostname') ||
|
||||||
result.stderr.includes('Permission denied')
|
result.stderr.includes('Permission denied'))
|
||||||
)) {
|
) {
|
||||||
connectionError = result.stderr.trim().split('\n')[0];
|
connectionError = result.stderr.trim().split('\n')[0];
|
||||||
logger.warn(`SSH connection error for ${sshConfig.host}: ${connectionError}`, LOG_CONTEXT);
|
logger.warn(
|
||||||
|
`SSH connection error for ${sshConfig.host}: ${connectionError}`,
|
||||||
|
LOG_CONTEXT
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip ANSI/OSC escape sequences from output
|
// Strip ANSI/OSC escape sequences from output
|
||||||
@@ -399,7 +409,10 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
logger.warn(`Failed to check agent "${agentDef.name}" on remote: ${errorMessage}`, LOG_CONTEXT);
|
logger.warn(
|
||||||
|
`Failed to check agent "${agentDef.name}" on remote: ${errorMessage}`,
|
||||||
|
LOG_CONTEXT
|
||||||
|
);
|
||||||
return stripAgentFunctions({
|
return stripAgentFunctions({
|
||||||
...agentDef,
|
...agentDef,
|
||||||
available: false,
|
available: false,
|
||||||
@@ -732,6 +745,15 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void {
|
|||||||
// Use custom path if provided, otherwise use detected path
|
// Use custom path if provided, otherwise use detected path
|
||||||
const commandPath = customPath || agent.path || agent.command;
|
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
|
// Spawn Claude with /help which immediately exits and costs no tokens
|
||||||
// The init message contains all available slash commands
|
// The init message contains all available slash commands
|
||||||
const args = [
|
const args = [
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* - Participant management (add, send, remove)
|
* - Participant management (add, send, remove)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as os from 'os';
|
||||||
import { ipcMain, BrowserWindow } from 'electron';
|
import { ipcMain, BrowserWindow } from 'electron';
|
||||||
import { withIpcErrorLogging, CreateHandlerOptions } from '../../utils/ipcHandler';
|
import { withIpcErrorLogging, CreateHandlerOptions } from '../../utils/ipcHandler';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
@@ -480,7 +481,7 @@ export function registerGroupChatHandlers(deps: GroupChatHandlerDependencies): v
|
|||||||
name,
|
name,
|
||||||
agentId,
|
agentId,
|
||||||
processManager,
|
processManager,
|
||||||
cwd || process.env.HOME || '/tmp',
|
cwd || os.homedir(),
|
||||||
agentDetector ?? undefined,
|
agentDetector ?? undefined,
|
||||||
agentConfigValues,
|
agentConfigValues,
|
||||||
customEnvVars
|
customEnvVars
|
||||||
@@ -558,7 +559,7 @@ export function registerGroupChatHandlers(deps: GroupChatHandlerDependencies): v
|
|||||||
|
|
||||||
// Get the group chat folder for file access
|
// Get the group chat folder for file access
|
||||||
const groupChatFolder = getGroupChatDir(groupChatId);
|
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
|
// 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}".
|
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
|
// Command Resolution: Apply session-level custom path override if set
|
||||||
// This allows users to override the detected agent path per-session
|
// This allows users to override the detected agent path per-session
|
||||||
//
|
//
|
||||||
// WINDOWS FIX: On Windows, prefer the resolved agent path with .exe extension
|
// NEW: Always use shell execution for agent processes on Windows (except SSH),
|
||||||
// to avoid using shell:true in ProcessManager. When shell:true is used,
|
// so PATH and other environment variables are available. This ensures cross-platform
|
||||||
// stdin piping through cmd.exe is unreliable - data written to stdin may not
|
// compatibility and correct agent behavior.
|
||||||
// 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.
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
let commandToSpawn = config.sessionCustomPath || config.command;
|
let commandToSpawn = config.sessionCustomPath || config.command;
|
||||||
let argsToSpawn = finalArgs;
|
let argsToSpawn = finalArgs;
|
||||||
|
let useShell = false;
|
||||||
|
let sshRemoteUsed: SshRemoteConfig | null = null;
|
||||||
|
let customEnvVarsToPass: Record<string, string> | undefined = effectiveCustomEnvVars;
|
||||||
|
|
||||||
if (config.sessionCustomPath) {
|
if (config.sessionCustomPath) {
|
||||||
logger.debug(`Using session-level custom path for ${config.toolType}`, LOG_CONTEXT, {
|
logger.debug(`Using session-level custom path for ${config.toolType}`, LOG_CONTEXT, {
|
||||||
customPath: config.sessionCustomPath,
|
customPath: config.sessionCustomPath,
|
||||||
originalCommand: config.command,
|
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
|
// SSH Remote Execution: Detect and wrap command for remote execution
|
||||||
// Terminal sessions are always local (they need PTY for shell interaction)
|
// 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
|
// Only consider SSH remote for non-terminal AI agent sessions
|
||||||
// SSH is session-level ONLY - no agent-level or global defaults
|
// SSH is session-level ONLY - no agent-level or global defaults
|
||||||
// Log SSH evaluation on Windows for debugging
|
// Log SSH evaluation on Windows for debugging
|
||||||
@@ -302,6 +319,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
|||||||
willUseSsh: config.toolType !== 'terminal' && config.sessionSshRemoteConfig?.enabled,
|
willUseSsh: config.toolType !== 'terminal' && config.sessionSshRemoteConfig?.enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let shouldSendPromptViaStdin = false;
|
||||||
if (config.toolType !== 'terminal' && config.sessionSshRemoteConfig?.enabled) {
|
if (config.toolType !== 'terminal' && config.sessionSshRemoteConfig?.enabled) {
|
||||||
// Session-level SSH config provided - resolve and use it
|
// Session-level SSH config provided - resolve and use it
|
||||||
logger.info(`Using session-level SSH config`, LOG_CONTEXT, {
|
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
|
// 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.
|
// Windows command line length limits (~8191 chars). SSH wrapping adds significant overhead.
|
||||||
// Instead, add --input-format stream-json and let ProcessManager send via stdin.
|
// 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 isLargePrompt = config.prompt && config.prompt.length > 4000;
|
||||||
|
const hasStreamJsonInput =
|
||||||
|
finalArgs.includes('--input-format') && finalArgs.includes('stream-json');
|
||||||
let sshArgs = finalArgs;
|
let sshArgs = finalArgs;
|
||||||
if (config.prompt && !isLargePrompt) {
|
if (config.prompt && !isLargePrompt && !hasStreamJsonInput) {
|
||||||
// Small prompt - embed in command line as usual
|
// Small prompt - embed in command line as usual (only if not using stream-json input)
|
||||||
if (agent?.promptArgs) {
|
if (agent?.promptArgs) {
|
||||||
sshArgs = [...finalArgs, ...agent.promptArgs(config.prompt)];
|
sshArgs = [...finalArgs, ...agent.promptArgs(config.prompt)];
|
||||||
} else if (agent?.noPromptSeparator) {
|
} else if (agent?.noPromptSeparator) {
|
||||||
@@ -338,14 +361,19 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
|||||||
} else {
|
} else {
|
||||||
sshArgs = [...finalArgs, '--', config.prompt];
|
sshArgs = [...finalArgs, '--', config.prompt];
|
||||||
}
|
}
|
||||||
} else if (config.prompt && isLargePrompt) {
|
} else if (config.prompt && (isLargePrompt || hasStreamJsonInput)) {
|
||||||
// Large prompt - use stdin mode
|
// Large prompt or stream-json input - ensure --input-format stream-json is present
|
||||||
// Add --input-format stream-json flag so agent reads from stdin
|
if (!hasStreamJsonInput) {
|
||||||
sshArgs = [...finalArgs, '--input-format', 'stream-json'];
|
sshArgs = [...finalArgs, '--input-format', 'stream-json'];
|
||||||
logger.info(`Using stdin for large prompt in SSH remote execution`, LOG_CONTEXT, {
|
}
|
||||||
|
shouldSendPromptViaStdin = true;
|
||||||
|
logger.info(`Using stdin for prompt in SSH remote execution`, LOG_CONTEXT, {
|
||||||
sessionId: config.sessionId,
|
sessionId: config.sessionId,
|
||||||
promptLength: config.prompt.length,
|
promptLength: config.prompt?.length,
|
||||||
reason: 'avoid-command-line-length-limit',
|
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
|
// and env vars are passed via the remote command string
|
||||||
requiresPty: sshRemoteUsed ? false : agent?.requiresPty,
|
requiresPty: sshRemoteUsed ? false : agent?.requiresPty,
|
||||||
// When using SSH with small prompts, the prompt was already added to sshArgs above
|
// 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:
|
prompt:
|
||||||
sshRemoteUsed && config.prompt && config.prompt.length > 4000
|
sshRemoteUsed && config.prompt && shouldSendPromptViaStdin
|
||||||
? config.prompt
|
? config.prompt
|
||||||
: sshRemoteUsed
|
: sshRemoteUsed
|
||||||
? undefined
|
? undefined
|
||||||
: config.prompt,
|
: config.prompt,
|
||||||
shell: shellToUse,
|
shell: shellToUse,
|
||||||
|
runInShell: useShell,
|
||||||
shellArgs: shellArgsStr, // Shell-specific CLI args (for terminal sessions)
|
shellArgs: shellArgsStr, // Shell-specific CLI args (for terminal sessions)
|
||||||
shellEnvVars: shellEnvVars, // Shell-specific env vars (for terminal sessions)
|
shellEnvVars: shellEnvVars, // Shell-specific env vars (for terminal sessions)
|
||||||
contextWindow, // Pass configured context window to process manager
|
contextWindow, // Pass configured context window to process manager
|
||||||
// When using SSH, env vars are passed in the remote command string, not locally
|
// 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)
|
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)
|
promptArgs: agent?.promptArgs, // Function to build prompt args (e.g., ['-p', prompt] for OpenCode)
|
||||||
noPromptSeparator: agent?.noPromptSeparator, // Some agents don't support '--' before prompt
|
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
|
// Stats tracking: use cwd as projectPath if not explicitly provided
|
||||||
projectPath: config.cwd,
|
projectPath: config.cwd,
|
||||||
// SSH remote context (for SSH-specific error messages)
|
// SSH remote context (for SSH-specific error messages)
|
||||||
|
|||||||
@@ -152,33 +152,32 @@ export class ChildProcessSpawner {
|
|||||||
// Handle Windows shell requirements
|
// Handle Windows shell requirements
|
||||||
const spawnCommand = command;
|
const spawnCommand = command;
|
||||||
let spawnArgs = finalArgs;
|
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) {
|
// Auto-enable shell for Windows when command is a bare .exe (no path)
|
||||||
const lowerCommand = command.toLowerCase();
|
const commandHasPath = /\\|\//.test(spawnCommand);
|
||||||
// Use shell for batch files
|
const commandExt = path.extname(spawnCommand).toLowerCase();
|
||||||
if (lowerCommand.endsWith('.cmd') || lowerCommand.endsWith('.bat')) {
|
if (isWindows && !useShell && !commandHasPath && commandExt === '.exe') {
|
||||||
useShell = true;
|
useShell = true;
|
||||||
logger.debug(
|
logger.info(
|
||||||
'[ProcessManager] Using shell=true for Windows batch file',
|
'[ProcessManager] Auto-enabling shell for Windows to allow PATH resolution of basename exe',
|
||||||
'ProcessManager',
|
'ProcessManager',
|
||||||
{ command }
|
{ command: spawnCommand }
|
||||||
);
|
|
||||||
} 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 }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
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
|
// Escape arguments for cmd.exe when using shell
|
||||||
if (useShell) {
|
|
||||||
spawnArgs = finalArgs.map((arg) => {
|
spawnArgs = finalArgs.map((arg) => {
|
||||||
const needsQuoting = /[ &|<>^%!()"\n\r#?*]/.test(arg) || arg.length > 100;
|
const needsQuoting = /[ &|<>^%!()"\n\r#?*]/.test(arg) || arg.length > 100;
|
||||||
if (needsQuoting) {
|
if (needsQuoting) {
|
||||||
@@ -195,6 +194,13 @@ export class ChildProcessSpawner {
|
|||||||
argsModified: finalArgs.some((arg, i) => arg !== spawnArgs[i]),
|
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
|
// Log spawn details
|
||||||
@@ -202,7 +208,8 @@ export class ChildProcessSpawner {
|
|||||||
spawnLogFn('[ProcessManager] About to spawn with shell option', 'ProcessManager', {
|
spawnLogFn('[ProcessManager] About to spawn with shell option', 'ProcessManager', {
|
||||||
sessionId,
|
sessionId,
|
||||||
spawnCommand,
|
spawnCommand,
|
||||||
useShell,
|
// show the actual shell value passed to spawn (boolean or shell path)
|
||||||
|
spawnShell: typeof spawnShell === 'string' ? spawnShell : !!spawnShell,
|
||||||
isWindows,
|
isWindows,
|
||||||
argsCount: spawnArgs.length,
|
argsCount: spawnArgs.length,
|
||||||
promptArgLength: prompt ? spawnArgs[spawnArgs.length - 1]?.length : undefined,
|
promptArgLength: prompt ? spawnArgs[spawnArgs.length - 1]?.length : undefined,
|
||||||
@@ -212,7 +219,7 @@ export class ChildProcessSpawner {
|
|||||||
const childProcess = spawn(spawnCommand, spawnArgs, {
|
const childProcess = spawn(spawnCommand, spawnArgs, {
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
shell: useShell,
|
shell: spawnShell,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -227,13 +234,14 @@ export class ChildProcessSpawner {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isBatchMode = !!prompt;
|
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 argsContain = (pattern: string) => finalArgs.some((arg) => arg.includes(pattern));
|
||||||
const isStreamJsonMode =
|
const isStreamJsonMode =
|
||||||
argsContain('stream-json') ||
|
argsContain('stream-json') ||
|
||||||
argsContain('--json') ||
|
argsContain('--json') ||
|
||||||
(argsContain('--format') && argsContain('json')) ||
|
(argsContain('--format') && argsContain('json')) ||
|
||||||
(hasImages && !!prompt);
|
(hasImages && !!prompt) ||
|
||||||
|
!!config.sendPromptViaStdin;
|
||||||
|
|
||||||
// Get the output parser for this agent type
|
// Get the output parser for this agent type
|
||||||
const outputParser = getOutputParser(toolType) || undefined;
|
const outputParser = getOutputParser(toolType) || undefined;
|
||||||
@@ -377,29 +385,17 @@ export class ChildProcessSpawner {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle stdin for batch mode and stream-json
|
// Handle stdin for batch mode and stream-json
|
||||||
if (isStreamJsonMode && prompt && images) {
|
if (isStreamJsonMode && prompt) {
|
||||||
// Stream-json mode with images: send the message via stdin
|
// Stream-json mode: send the message via stdin
|
||||||
const streamJsonMessage = buildStreamJsonMessage(prompt, images);
|
const streamJsonMessage = buildStreamJsonMessage(prompt, images || []);
|
||||||
logger.debug('[ProcessManager] Sending stream-json message with images', 'ProcessManager', {
|
logger.debug('[ProcessManager] Sending stream-json message via stdin', 'ProcessManager', {
|
||||||
sessionId,
|
sessionId,
|
||||||
messageLength: streamJsonMessage.length,
|
messageLength: streamJsonMessage.length,
|
||||||
imageCount: images.length,
|
imageCount: (images || []).length,
|
||||||
|
hasImages: !!(images && images.length > 0),
|
||||||
});
|
});
|
||||||
childProcess.stdin?.write(streamJsonMessage + '\n');
|
childProcess.stdin?.write(streamJsonMessage + '\n');
|
||||||
childProcess.stdin?.end();
|
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) {
|
} else if (isBatchMode) {
|
||||||
// Regular batch mode: close stdin immediately
|
// Regular batch mode: close stdin immediately
|
||||||
logger.debug('[ProcessManager] Closing stdin for batch mode', 'ProcessManager', {
|
logger.debug('[ProcessManager] Closing stdin for batch mode', 'ProcessManager', {
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export interface ProcessConfig {
|
|||||||
querySource?: 'user' | 'auto';
|
querySource?: 'user' | 'auto';
|
||||||
tabId?: string;
|
tabId?: string;
|
||||||
projectPath?: 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 os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { STANDARD_UNIX_PATHS } from '../constants';
|
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.
|
* Build the base PATH for macOS/Linux with detected Node version manager paths.
|
||||||
@@ -58,40 +58,10 @@ export function buildChildProcessEnv(
|
|||||||
customEnvVars?: Record<string, string>,
|
customEnvVars?: Record<string, string>,
|
||||||
isResuming?: boolean
|
isResuming?: boolean
|
||||||
): NodeJS.ProcessEnv {
|
): NodeJS.ProcessEnv {
|
||||||
const isWindows = process.platform === 'win32';
|
|
||||||
const home = os.homedir();
|
|
||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
|
|
||||||
// Platform-specific standard paths
|
// Use the shared expanded PATH
|
||||||
let standardPaths: string;
|
env.PATH = buildExpandedPath();
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isResuming) {
|
if (isResuming) {
|
||||||
env.MAESTRO_SESSION_RESUMED = '1';
|
env.MAESTRO_SESSION_RESUMED = '1';
|
||||||
@@ -99,6 +69,7 @@ export function buildChildProcessEnv(
|
|||||||
|
|
||||||
// Apply custom environment variables
|
// Apply custom environment variables
|
||||||
if (customEnvVars && Object.keys(customEnvVars).length > 0) {
|
if (customEnvVars && Object.keys(customEnvVars).length > 0) {
|
||||||
|
const home = os.homedir();
|
||||||
for (const [key, value] of Object.entries(customEnvVars)) {
|
for (const [key, value] of Object.entries(customEnvVars)) {
|
||||||
env[key] = value.startsWith('~/') ? path.join(home, value.slice(2)) : value;
|
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 {
|
export function buildStreamJsonMessage(prompt: string, images: string[]): string {
|
||||||
const content: MessageContent[] = [];
|
const content: MessageContent[] = [];
|
||||||
|
|
||||||
// Add images first
|
// Add text content first
|
||||||
for (const dataUrl of images) {
|
content.push({
|
||||||
const parsed = parseDataUrl(dataUrl);
|
type: 'text',
|
||||||
|
text: prompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add image content for each image
|
||||||
|
for (const imageDataUrl of images) {
|
||||||
|
const parsed = parseDataUrl(imageDataUrl);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
content.push({
|
content.push({
|
||||||
type: 'image',
|
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 = {
|
const message = {
|
||||||
type: 'user',
|
type: 'user_message',
|
||||||
message: {
|
|
||||||
role: 'user',
|
|
||||||
content,
|
content,
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return JSON.stringify(message);
|
return JSON.stringify(message);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { execFileNoThrow } from './execFile';
|
import { execFileNoThrow } from './execFile';
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { buildExpandedEnv } from '../../shared/pathUtils';
|
||||||
|
|
||||||
let cloudflaredInstalledCache: boolean | null = null;
|
let cloudflaredInstalledCache: boolean | null = null;
|
||||||
let cloudflaredPathCache: string | 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.
|
* This is necessary because packaged Electron apps don't inherit shell environment.
|
||||||
*/
|
*/
|
||||||
export function getExpandedEnv(): NodeJS.ProcessEnv {
|
export function getExpandedEnv(): NodeJS.ProcessEnv {
|
||||||
const home = os.homedir();
|
return buildExpandedEnv();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isCloudflaredInstalled(): Promise<boolean> {
|
export async function isCloudflaredInstalled(): Promise<boolean> {
|
||||||
@@ -80,7 +32,9 @@ export async function isCloudflaredInstalled(): Promise<boolean> {
|
|||||||
|
|
||||||
if (result.exitCode === 0 && result.stdout.trim()) {
|
if (result.exitCode === 0 && result.stdout.trim()) {
|
||||||
cloudflaredInstalledCache = true;
|
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 {
|
} else {
|
||||||
cloudflaredInstalledCache = false;
|
cloudflaredInstalledCache = false;
|
||||||
}
|
}
|
||||||
@@ -115,7 +69,9 @@ export async function isGhInstalled(): Promise<boolean> {
|
|||||||
if (result.exitCode === 0 && result.stdout.trim()) {
|
if (result.exitCode === 0 && result.stdout.trim()) {
|
||||||
ghInstalledCache = true;
|
ghInstalledCache = true;
|
||||||
// On Windows, 'where' can return multiple paths - take the first one
|
// 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 {
|
} else {
|
||||||
ghInstalledCache = false;
|
ghInstalledCache = false;
|
||||||
}
|
}
|
||||||
@@ -209,7 +165,10 @@ export async function detectSshPath(): Promise<string | null> {
|
|||||||
const result = await execFileNoThrow(command, ['ssh'], undefined, env);
|
const result = await execFileNoThrow(command, ['ssh'], undefined, env);
|
||||||
|
|
||||||
if (result.exitCode === 0 && result.stdout.trim()) {
|
if (result.exitCode === 0 && result.stdout.trim()) {
|
||||||
sshPathCache = result.stdout.trim().split('\n')[0];
|
// 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') {
|
} else if (process.platform === 'win32') {
|
||||||
// Fallback for Windows: Check the built-in OpenSSH location directly
|
// Fallback for Windows: Check the built-in OpenSSH location directly
|
||||||
// This is the standard location for Windows 10/11 OpenSSH
|
// This is the standard location for Windows 10/11 OpenSSH
|
||||||
|
|||||||
@@ -226,3 +226,159 @@ export function detectNodeVersionManagerBinPaths(): string[] {
|
|||||||
|
|
||||||
return detectedPaths;
|
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