mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 00:21:21 +00:00
fix: Windows PATH issues and SSH remote execution improvements
- Fix Windows agent execution by using buildExpandedEnv for proper PATH expansion - Add PowerShell support for SSH commands to handle long command lines (32K+ chars) - Implement here document support for large OpenCode prompts over SSH - Add raw stdin prompt sending for agents without stream-json support - Restrict inline wizard to Claude, Claude Code, and Codex (OpenCode incompatible) - Improve argument escaping for both cmd.exe and PowerShell shells - Update tsconfig.main.json to include shared files for proper compilation - Enhance agent path resolution for packaged Electron applications - Add read-only mode for OpenCode in wizard conversations - Update tests and UI components for better SSH remote configuration
This commit is contained in:
@@ -392,11 +392,10 @@ describe('agent-detector', () => {
|
||||
expect.stringContaining('Agent detection starting'),
|
||||
'AgentDetector'
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Agent detection complete'),
|
||||
'AgentDetector',
|
||||
expect.any(Object)
|
||||
);
|
||||
const calls = logger.info.mock.calls;
|
||||
const completeCall = calls.find((call) => call[0].includes('Agent detection complete'));
|
||||
expect(completeCall).toBeDefined();
|
||||
expect(completeCall[1]).toBe('AgentDetector');
|
||||
});
|
||||
|
||||
it('should log when agents are found', async () => {
|
||||
|
||||
@@ -376,7 +376,7 @@ describe('Auto Run Folder Validation', () => {
|
||||
expect(validatePathWithinFolder(joined, folderPath)).toBe(true); // It's still within the folder
|
||||
} else {
|
||||
// On Unix, path.join with absolute second arg gives the absolute path
|
||||
expect(joined).toBe('/etc/passwd');
|
||||
expect(joined).toBe('/test/autorun/etc/passwd');
|
||||
expect(validatePathWithinFolder(joined, folderPath)).toBe(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -240,6 +240,9 @@ describe('process IPC handlers', () => {
|
||||
name: 'Claude Code',
|
||||
requiresPty: true,
|
||||
path: '/usr/local/bin/claude',
|
||||
capabilities: {
|
||||
supportsStreamJsonInput: true,
|
||||
},
|
||||
};
|
||||
|
||||
mockAgentDetector.getAgent.mockResolvedValue(mockAgent);
|
||||
@@ -866,6 +869,9 @@ describe('process IPC handlers', () => {
|
||||
const mockAgent = {
|
||||
id: 'claude-code',
|
||||
requiresPty: true, // Note: should be disabled when using SSH
|
||||
capabilities: {
|
||||
supportsStreamJsonInput: true,
|
||||
},
|
||||
};
|
||||
|
||||
mockAgentDetector.getAgent.mockResolvedValue(mockAgent);
|
||||
@@ -946,6 +952,9 @@ describe('process IPC handlers', () => {
|
||||
const mockAgent = {
|
||||
id: 'claude-code',
|
||||
requiresPty: false,
|
||||
capabilities: {
|
||||
supportsStreamJsonInput: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Mock applyAgentConfigOverrides to return custom env vars
|
||||
@@ -1073,6 +1082,9 @@ describe('process IPC handlers', () => {
|
||||
const mockAgent = {
|
||||
id: 'claude-code',
|
||||
requiresPty: false,
|
||||
capabilities: {
|
||||
supportsStreamJsonInput: true,
|
||||
},
|
||||
};
|
||||
|
||||
mockAgentDetector.getAgent.mockResolvedValue(mockAgent);
|
||||
@@ -1120,6 +1132,9 @@ describe('process IPC handlers', () => {
|
||||
binaryName: 'codex', // Just the binary name, without path
|
||||
path: '/opt/homebrew/bin/codex', // Local macOS path
|
||||
requiresPty: false,
|
||||
capabilities: {
|
||||
supportsStreamJsonInput: false,
|
||||
},
|
||||
};
|
||||
|
||||
mockAgentDetector.getAgent.mockResolvedValue(mockAgent);
|
||||
@@ -1161,6 +1176,9 @@ describe('process IPC handlers', () => {
|
||||
binaryName: 'codex',
|
||||
path: '/opt/homebrew/bin/codex', // Local path
|
||||
requiresPty: false,
|
||||
capabilities: {
|
||||
supportsStreamJsonInput: false,
|
||||
},
|
||||
};
|
||||
|
||||
mockAgentDetector.getAgent.mockResolvedValue(mockAgent);
|
||||
|
||||
@@ -547,10 +547,10 @@ describe('useInputProcessing', () => {
|
||||
isBuiltIn: true,
|
||||
},
|
||||
{
|
||||
id: 'speckit-specify',
|
||||
command: '/commit',
|
||||
description: 'Create a feature spec',
|
||||
prompt: 'Create a spec for: $ARGUMENTS',
|
||||
id: 'test-command',
|
||||
command: '/testcommand',
|
||||
description: 'Test command',
|
||||
prompt: 'Test: $ARGUMENTS',
|
||||
isBuiltIn: true,
|
||||
},
|
||||
];
|
||||
@@ -559,7 +559,7 @@ describe('useInputProcessing', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const deps = createDeps({
|
||||
inputValue: '/speckit.constitution Blah blah blah',
|
||||
inputValue: '/testcommand Blah blah blah',
|
||||
customAICommands: speckitCommandsWithArgs,
|
||||
});
|
||||
const { result } = renderHook(() => useInputProcessing(deps));
|
||||
@@ -583,7 +583,7 @@ describe('useInputProcessing', () => {
|
||||
const queuedItem = callArgs[1] as QueuedItem;
|
||||
|
||||
expect(queuedItem.type).toBe('command');
|
||||
expect(queuedItem.command).toBe('/speckit.constitution');
|
||||
expect(queuedItem.command).toBe('/testcommand');
|
||||
expect(queuedItem.commandArgs).toBe('Blah blah blah');
|
||||
|
||||
vi.useRealTimers();
|
||||
@@ -619,7 +619,7 @@ describe('useInputProcessing', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const deps = createDeps({
|
||||
inputValue: '/commit Add user authentication with OAuth 2.0 support',
|
||||
inputValue: '/testcommand Add user authentication with OAuth 2.0 support',
|
||||
customAICommands: speckitCommandsWithArgs,
|
||||
});
|
||||
const { result } = renderHook(() => useInputProcessing(deps));
|
||||
@@ -633,7 +633,7 @@ describe('useInputProcessing', () => {
|
||||
});
|
||||
|
||||
const queuedItem = mockProcessQueuedItemRef.current.mock.calls[0][1] as QueuedItem;
|
||||
expect(queuedItem.command).toBe('/commit');
|
||||
expect(queuedItem.command).toBe('/testcommand');
|
||||
expect(queuedItem.commandArgs).toBe('Add user authentication with OAuth 2.0 support');
|
||||
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -269,7 +269,7 @@ export const AGENT_CAPABILITIES: Record<string, AgentCapabilities> = {
|
||||
supportsStreaming: true, // Streams JSONL events - Verified
|
||||
supportsResultMessages: true, // step_finish with part.reason:"stop" - Verified
|
||||
supportsModelSelection: true, // --model provider/model (e.g., 'ollama/qwen3:8b') - Verified
|
||||
supportsStreamJsonInput: false, // Uses -f, --file flag instead
|
||||
supportsStreamJsonInput: false, // Uses positional arguments for prompt
|
||||
supportsThinkingDisplay: true, // Emits streaming text chunks
|
||||
supportsContextMerge: true, // Can receive merged context via prompts
|
||||
supportsContextExport: true, // Session storage supports context export
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../../utils/ipcHandler';
|
||||
import { getSshRemoteConfig, createSshRemoteStoreAdapter } from '../../utils/ssh-remote-resolver';
|
||||
import { buildSshCommand } from '../../utils/ssh-command-builder';
|
||||
import { buildExpandedEnv } from '../../../shared/pathUtils';
|
||||
import type { SshRemoteConfig } from '../../../shared/types';
|
||||
import { powerManager } from '../../power-manager';
|
||||
import { MaestroSettings } from './persistence';
|
||||
@@ -262,6 +263,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
let useShell = false;
|
||||
let sshRemoteUsed: SshRemoteConfig | null = null;
|
||||
let customEnvVarsToPass: Record<string, string> | undefined = effectiveCustomEnvVars;
|
||||
let useHereDocForOpenCode = false;
|
||||
|
||||
if (config.sessionCustomPath) {
|
||||
logger.debug(`Using session-level custom path for ${config.toolType}`, LOG_CONTEXT, {
|
||||
@@ -273,13 +275,11 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
// 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)
|
||||
// Use expanded environment with custom env vars to ensure PATH includes all binary locations
|
||||
const expandedEnv = buildExpandedEnv(customEnvVarsToPass);
|
||||
// Filter out undefined values to match Record<string, string> type
|
||||
customEnvVarsToPass = Object.fromEntries(
|
||||
Object.entries({
|
||||
...process.env,
|
||||
...(customEnvVarsToPass || {}),
|
||||
}).filter(([_, v]) => typeof v === 'string')
|
||||
Object.entries(expandedEnv).filter(([_, value]) => value !== undefined)
|
||||
) as Record<string, string>;
|
||||
|
||||
// Determine an explicit shell to use when forcing shell execution on Windows.
|
||||
@@ -320,6 +320,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
});
|
||||
}
|
||||
let shouldSendPromptViaStdin = false;
|
||||
let shouldSendPromptViaStdinRaw = false;
|
||||
if (config.toolType !== 'terminal' && config.sessionSshRemoteConfig?.enabled) {
|
||||
// Session-level SSH config provided - resolve and use it
|
||||
logger.info(`Using session-level SSH config`, LOG_CONTEXT, {
|
||||
@@ -351,6 +352,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
const isLargePrompt = config.prompt && config.prompt.length > 4000;
|
||||
const hasStreamJsonInput =
|
||||
finalArgs.includes('--input-format') && finalArgs.includes('stream-json');
|
||||
const agentSupportsStreamJson = agent?.capabilities.supportsStreamJsonInput ?? false;
|
||||
let sshArgs = finalArgs;
|
||||
if (config.prompt && !isLargePrompt && !hasStreamJsonInput) {
|
||||
// Small prompt - embed in command line as usual (only if not using stream-json input)
|
||||
@@ -361,8 +363,12 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
} else {
|
||||
sshArgs = [...finalArgs, '--', config.prompt];
|
||||
}
|
||||
} else if (config.prompt && (isLargePrompt || hasStreamJsonInput)) {
|
||||
// Large prompt or stream-json input - ensure --input-format stream-json is present
|
||||
} else if (
|
||||
config.prompt &&
|
||||
(isLargePrompt || hasStreamJsonInput) &&
|
||||
agentSupportsStreamJson
|
||||
) {
|
||||
// Large prompt or stream-json input, and agent supports it - use stdin
|
||||
if (!hasStreamJsonInput) {
|
||||
sshArgs = [...finalArgs, '--input-format', 'stream-json'];
|
||||
}
|
||||
@@ -375,16 +381,43 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
: 'stream-json-input-mode',
|
||||
hasStreamJsonInput,
|
||||
});
|
||||
} else if (config.prompt && isLargePrompt && !agentSupportsStreamJson) {
|
||||
// Large prompt but agent doesn't support stream-json
|
||||
if (config.toolType === 'opencode') {
|
||||
// OpenCode: mark for here document processing (will be handled after remoteCommand is set)
|
||||
useHereDocForOpenCode = true;
|
||||
} else {
|
||||
// Other agents: send via stdin as raw text
|
||||
shouldSendPromptViaStdinRaw = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the SSH command that wraps the agent execution
|
||||
//
|
||||
// Determine the command to run on the remote host:
|
||||
// 1. If user set a session-specific custom path, use that (they configured it for the remote)
|
||||
// 2. Otherwise, use the agent's binaryName (e.g., 'codex', 'claude') and let
|
||||
// the remote shell's PATH resolve it. This avoids using local paths like
|
||||
// '/opt/homebrew/bin/codex' which don't exist on the remote host.
|
||||
const remoteCommand = config.sessionCustomPath || agent?.binaryName || config.command;
|
||||
let remoteCommand = config.sessionCustomPath || agent?.binaryName || config.command;
|
||||
|
||||
// Handle OpenCode here document for large prompts
|
||||
if (useHereDocForOpenCode && config.prompt) {
|
||||
// OpenCode: use here document to avoid command line limits
|
||||
// Escape single quotes in the prompt for bash here document
|
||||
const escapedPrompt = config.prompt.replace(/'/g, "'\\''");
|
||||
// Construct: cat << 'EOF' | opencode run --format json\nlong prompt here\nEOF
|
||||
const hereDocCommand = `cat << 'EOF' | ${remoteCommand} ${sshArgs.join(' ')}\n${escapedPrompt}\nEOF`;
|
||||
sshArgs = []; // Clear args since they're now in the here doc command
|
||||
remoteCommand = hereDocCommand; // Update to use here document
|
||||
logger.info(
|
||||
`Using here document for large OpenCode prompt to avoid command line limits`,
|
||||
LOG_CONTEXT,
|
||||
{
|
||||
sessionId: config.sessionId,
|
||||
promptLength: config.prompt?.length,
|
||||
commandLength: hereDocCommand.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
// Decide whether we'll send input via stdin to the remote command
|
||||
const useStdin = sshArgs.includes('--input-format') && sshArgs.includes('stream-json');
|
||||
|
||||
@@ -406,6 +439,21 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
// For SSH, env vars are passed in the remote command string, not locally
|
||||
customEnvVarsToPass = undefined;
|
||||
|
||||
// On Windows, use PowerShell for SSH commands to avoid cmd.exe's 8191 character limit
|
||||
// PowerShell supports up to 32,767 characters, which is needed for large prompts
|
||||
if (isWindows) {
|
||||
useShell = true;
|
||||
shellToUse = 'powershell.exe';
|
||||
logger.info(
|
||||
`Using PowerShell for SSH command on Windows to support long command lines`,
|
||||
LOG_CONTEXT,
|
||||
{
|
||||
sessionId: config.sessionId,
|
||||
commandLength: sshCommand.args.join(' ').length,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Detailed debug logging to diagnose SSH command execution issues
|
||||
logger.debug(`SSH command details for debugging`, LOG_CONTEXT, {
|
||||
sessionId: config.sessionId,
|
||||
@@ -427,6 +475,15 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logging for shell configuration
|
||||
logger.info(`Shell configuration before spawn`, LOG_CONTEXT, {
|
||||
sessionId: config.sessionId,
|
||||
useShell,
|
||||
shellToUse,
|
||||
isWindows,
|
||||
isSshCommand: !!sshRemoteUsed,
|
||||
});
|
||||
|
||||
const result = processManager.spawn({
|
||||
...config,
|
||||
command: commandToSpawn,
|
||||
@@ -458,6 +515,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
noPromptSeparator: agent?.noPromptSeparator, // Some agents don't support '--' before prompt
|
||||
// For SSH with stream-json input, send prompt via stdin instead of command line
|
||||
sendPromptViaStdin: shouldSendPromptViaStdin ? true : undefined,
|
||||
sendPromptViaStdinRaw: shouldSendPromptViaStdinRaw ? true : undefined,
|
||||
// Stats tracking: use cwd as projectPath if not explicitly provided
|
||||
projectPath: config.cwd,
|
||||
// SSH remote context (for SSH-specific error messages)
|
||||
|
||||
@@ -177,22 +177,47 @@ export class ChildProcessSpawner {
|
||||
'ProcessManager',
|
||||
{ command: spawnCommand }
|
||||
);
|
||||
// Escape arguments for cmd.exe when using shell
|
||||
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]),
|
||||
});
|
||||
|
||||
// Check if we're using PowerShell (for SSH commands to avoid cmd.exe 8191 char limit)
|
||||
const isPowerShell =
|
||||
typeof config.shell === 'string' && config.shell.toLowerCase().includes('powershell');
|
||||
|
||||
if (isPowerShell) {
|
||||
// Escape arguments for PowerShell (supports longer command lines than cmd.exe)
|
||||
spawnArgs = finalArgs.map((arg) => {
|
||||
const needsQuoting = /[ &|<>^%!()"\n\r#?*`$]/.test(arg) || arg.length > 100;
|
||||
if (needsQuoting) {
|
||||
// PowerShell escaping: wrap in single quotes, escape single quotes by doubling
|
||||
const escaped = arg.replace(/'/g, "''");
|
||||
return `'${escaped}'`;
|
||||
}
|
||||
return arg;
|
||||
});
|
||||
logger.info('[ProcessManager] Escaped args for PowerShell', '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]),
|
||||
});
|
||||
} else {
|
||||
// Escape arguments for cmd.exe when using shell
|
||||
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.
|
||||
@@ -241,7 +266,8 @@ export class ChildProcessSpawner {
|
||||
argsContain('--json') ||
|
||||
(argsContain('--format') && argsContain('json')) ||
|
||||
(hasImages && !!prompt) ||
|
||||
!!config.sendPromptViaStdin;
|
||||
!!config.sendPromptViaStdin ||
|
||||
!!config.sendPromptViaStdinRaw;
|
||||
|
||||
// Get the output parser for this agent type
|
||||
const outputParser = getOutputParser(toolType) || undefined;
|
||||
@@ -253,8 +279,10 @@ export class ChildProcessSpawner {
|
||||
parserId: outputParser?.agentId,
|
||||
isStreamJsonMode,
|
||||
isBatchMode,
|
||||
command: config.command,
|
||||
argsCount: finalArgs.length,
|
||||
argsPreview:
|
||||
finalArgs.length > 0 ? finalArgs[finalArgs.length - 1]?.substring(0, 200) : undefined,
|
||||
finalArgs.length > 0 ? finalArgs[finalArgs.length - 1]?.substring(0, 500) : undefined,
|
||||
});
|
||||
|
||||
const managedProcess: ManagedProcess = {
|
||||
@@ -386,16 +414,26 @@ export class ChildProcessSpawner {
|
||||
|
||||
// Handle stdin for batch mode and stream-json
|
||||
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,
|
||||
hasImages: !!(images && images.length > 0),
|
||||
});
|
||||
childProcess.stdin?.write(streamJsonMessage + '\n');
|
||||
childProcess.stdin?.end();
|
||||
if (config.sendPromptViaStdinRaw) {
|
||||
// Send raw prompt via stdin
|
||||
logger.debug('[ProcessManager] Sending raw prompt via stdin', 'ProcessManager', {
|
||||
sessionId,
|
||||
promptLength: prompt.length,
|
||||
});
|
||||
childProcess.stdin?.write(prompt);
|
||||
childProcess.stdin?.end();
|
||||
} else {
|
||||
// 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,
|
||||
hasImages: !!(images && images.length > 0),
|
||||
});
|
||||
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', {
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface ProcessConfig {
|
||||
runInShell?: boolean;
|
||||
/** If true, send the prompt via stdin as JSON instead of command line */
|
||||
sendPromptViaStdin?: boolean;
|
||||
/** If true, send the prompt via stdin as raw text instead of command line */
|
||||
sendPromptViaStdinRaw?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -101,13 +101,21 @@ export function buildRemoteCommand(options: RemoteCommandOptions): string {
|
||||
// Build the command with arguments
|
||||
const commandWithArgs = buildShellCommand(command, args);
|
||||
|
||||
// If command expects JSON via stdin (stream-json), use exec to replace the
|
||||
// shell process so stdin is delivered directly to the agent binary and no
|
||||
// intermediate shell produces control sequences that could corrupt the stream.
|
||||
const hasStreamJsonInput = options.useStdin
|
||||
? true
|
||||
: Array.isArray(args) && args.includes('--input-format') && args.includes('stream-json');
|
||||
const finalCommandWithArgs = hasStreamJsonInput ? `exec ${commandWithArgs}` : commandWithArgs;
|
||||
// Handle stdin input modes
|
||||
let finalCommandWithArgs: string;
|
||||
if (options.useStdin) {
|
||||
const hasStreamJsonInput =
|
||||
Array.isArray(args) && args.includes('--input-format') && args.includes('stream-json');
|
||||
if (hasStreamJsonInput) {
|
||||
// Stream-JSON mode: use exec to avoid shell control sequences
|
||||
finalCommandWithArgs = `exec ${commandWithArgs}`;
|
||||
} else {
|
||||
// Raw prompt mode: pipe stdin directly to the command
|
||||
finalCommandWithArgs = commandWithArgs;
|
||||
}
|
||||
} else {
|
||||
finalCommandWithArgs = commandWithArgs;
|
||||
}
|
||||
|
||||
// Combine env exports with command
|
||||
let fullCommand: string;
|
||||
|
||||
@@ -318,7 +318,8 @@ export function NewInstanceModal({
|
||||
// (hidden agents like 'terminal' should never be auto-selected)
|
||||
if (source) {
|
||||
setSelectedAgent(source.toolType);
|
||||
} else {
|
||||
} else if (!sshRemoteId) {
|
||||
// Only auto-select on initial load, not on SSH remote re-detection
|
||||
const firstAvailable = detectedAgents.find((a: AgentConfig) => a.available && !a.hidden);
|
||||
if (firstAvailable) {
|
||||
setSelectedAgent(firstAvailable.id);
|
||||
@@ -578,34 +579,38 @@ export function NewInstanceModal({
|
||||
}
|
||||
}, [isOpen, sourceSession]);
|
||||
|
||||
// Load SSH remote configurations independently of agent detection
|
||||
// This ensures SSH remotes are available even if agent detection fails
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const loadSshConfigs = async () => {
|
||||
try {
|
||||
const sshConfigsResult = await window.maestro.sshRemote.getConfigs();
|
||||
if (sshConfigsResult.success && sshConfigsResult.configs) {
|
||||
setSshRemotes(sshConfigsResult.configs);
|
||||
}
|
||||
} catch (sshError) {
|
||||
console.error('Failed to load SSH remote configs:', sshError);
|
||||
}
|
||||
};
|
||||
loadSshConfigs();
|
||||
}
|
||||
}, [isOpen]);
|
||||
// Load SSH remote configurations independently of agent detection
|
||||
// This ensures SSH remotes are available even if agent detection fails
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const loadSshConfigs = async () => {
|
||||
try {
|
||||
const sshConfigsResult = await window.maestro.sshRemote.getConfigs();
|
||||
if (sshConfigsResult.success && sshConfigsResult.configs) {
|
||||
setSshRemotes(sshConfigsResult.configs);
|
||||
}
|
||||
} catch (sshError) {
|
||||
console.error('Failed to load SSH remote configs:', sshError);
|
||||
}
|
||||
};
|
||||
loadSshConfigs();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Transfer pending SSH config to selected agent automatically
|
||||
// This ensures SSH config is preserved when agent is auto-selected or manually clicked
|
||||
useEffect(() => {
|
||||
if (selectedAgent && agentSshRemoteConfigs['_pending_'] && !agentSshRemoteConfigs[selectedAgent]) {
|
||||
setAgentSshRemoteConfigs(prev => ({
|
||||
...prev,
|
||||
[selectedAgent]: prev['_pending_'],
|
||||
}));
|
||||
}
|
||||
}, [selectedAgent, agentSshRemoteConfigs]);
|
||||
// Transfer pending SSH config to selected agent automatically
|
||||
// This ensures SSH config is preserved when agent is auto-selected or manually clicked
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedAgent &&
|
||||
agentSshRemoteConfigs['_pending_'] &&
|
||||
!agentSshRemoteConfigs[selectedAgent]
|
||||
) {
|
||||
setAgentSshRemoteConfigs((prev) => ({
|
||||
...prev,
|
||||
[selectedAgent]: prev['_pending_'],
|
||||
}));
|
||||
}
|
||||
}, [selectedAgent, agentSshRemoteConfigs]);
|
||||
|
||||
// Track the current SSH remote ID for re-detection
|
||||
// Uses _pending_ key when no agent is selected, which is the shared SSH config
|
||||
@@ -644,7 +649,6 @@ export function NewInstanceModal({
|
||||
|
||||
// Re-run agent detection with the new SSH remote ID
|
||||
loadAgents(undefined, currentSshRemoteId ?? undefined);
|
||||
|
||||
}, [isOpen, currentSshRemoteId]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
@@ -1131,11 +1135,13 @@ export function NewInstanceModal({
|
||||
agentSshRemoteConfigs[selectedAgent] || agentSshRemoteConfigs['_pending_']
|
||||
}
|
||||
onSshRemoteConfigChange={(config) => {
|
||||
const key = selectedAgent || '_pending_';
|
||||
setAgentSshRemoteConfigs((prev) => ({
|
||||
...prev,
|
||||
[key]: config,
|
||||
}));
|
||||
setAgentSshRemoteConfigs((prev) => {
|
||||
const newConfigs = { ...prev, _pending_: config };
|
||||
if (selectedAgent) {
|
||||
newConfigs[selectedAgent] = config;
|
||||
}
|
||||
return newConfigs;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -469,7 +469,6 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
|
||||
// Using JSON.stringify with 'null' fallback to ensure the effect runs when switching
|
||||
// between remote and local (JSON.stringify(undefined) returns undefined, not 'null',
|
||||
// so we need the fallback to ensure React sees it as a real string change)
|
||||
|
||||
}, [setAvailableAgents, setSelectedAgent, JSON.stringify(sshRemoteConfig) ?? 'null']);
|
||||
|
||||
// Load SSH remote configurations on mount
|
||||
@@ -858,9 +857,54 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
<h3 className="text-xl font-semibold" style={{ color: theme.colors.textMain }}>
|
||||
Configure {configuringTile.name}
|
||||
</h3>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<h3 className="text-xl font-semibold" style={{ color: theme.colors.textMain }}>
|
||||
Configure {configuringTile.name}
|
||||
</h3>
|
||||
{/* SSH Remote Location Dropdown - only shown if remotes are configured */}
|
||||
{sshRemotes.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span style={{ color: theme.colors.textDim }}>on</span>
|
||||
<select
|
||||
value={sshRemoteConfig?.enabled ? sshRemoteConfig.remoteId || '' : ''}
|
||||
onChange={(e) => {
|
||||
const remoteId = e.target.value;
|
||||
if (remoteId === '') {
|
||||
// Local machine selected
|
||||
setSshRemoteConfig(undefined);
|
||||
// Also update wizard context immediately
|
||||
setWizardSessionSshRemoteConfig({ enabled: false, remoteId: null });
|
||||
} else {
|
||||
// Remote selected
|
||||
setSshRemoteConfig({
|
||||
enabled: true,
|
||||
remoteId,
|
||||
});
|
||||
// Also update wizard context immediately
|
||||
setWizardSessionSshRemoteConfig({
|
||||
enabled: true,
|
||||
remoteId,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1 rounded border outline-none transition-all cursor-pointer text-xs"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgMain,
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.textMain,
|
||||
}}
|
||||
aria-label="Agent location"
|
||||
>
|
||||
<option value="">Local Machine</option>
|
||||
{sshRemotes.map((remote) => (
|
||||
<option key={remote.id} value={remote.id}>
|
||||
{remote.name || remote.host}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-20" /> {/* Spacer for centering */}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -651,12 +651,7 @@ class ConversationManager {
|
||||
// Must include these explicitly since wizard pre-builds args before IPC handler
|
||||
const args = [];
|
||||
|
||||
// Add batch mode prefix: 'exec'
|
||||
if (agent.batchModePrefix) {
|
||||
args.push(...agent.batchModePrefix);
|
||||
}
|
||||
|
||||
// Add base args (if any)
|
||||
// Add base args (if any) - batchModePrefix will be added by buildAgentArgs
|
||||
args.push(...(agent.args || []));
|
||||
|
||||
// Add batch mode args: '--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check'
|
||||
@@ -676,12 +671,7 @@ class ConversationManager {
|
||||
// OpenCode requires 'run' batch mode with JSON output for wizard conversations
|
||||
const args = [];
|
||||
|
||||
// Add batch mode prefix: 'run'
|
||||
if (agent.batchModePrefix) {
|
||||
args.push(...agent.batchModePrefix);
|
||||
}
|
||||
|
||||
// Add base args (if any)
|
||||
// Add base args (if any) - batchModePrefix will be added by buildAgentArgs
|
||||
args.push(...(agent.args || []));
|
||||
|
||||
// Add JSON output: '--format json'
|
||||
|
||||
@@ -207,7 +207,7 @@ export interface UseInlineWizardReturn {
|
||||
* @param tabId - The tab ID to associate the wizard with
|
||||
* @param sessionId - The session ID for playbook creation
|
||||
* @param autoRunFolderPath - User-configured Auto Run folder path (if set, overrides default projectPath/Auto Run Docs)
|
||||
* @param sessionSshRemoteConfig - SSH remote configuration (for remote execution)
|
||||
* @param sessionSshRemoteConfig - SSH remote configuration (for remote execution)
|
||||
*/
|
||||
startWizard: (
|
||||
naturalLanguageInput?: string,
|
||||
@@ -525,7 +525,7 @@ export function useInlineWizard(): UseInlineWizardReturn {
|
||||
subfolderName: null,
|
||||
subfolderPath: null,
|
||||
autoRunFolderPath: effectiveAutoRunFolderPath,
|
||||
sessionSshRemoteConfig,
|
||||
sessionSshRemoteConfig,
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -585,7 +585,14 @@ export function useInlineWizard(): UseInlineWizardReturn {
|
||||
}
|
||||
|
||||
// Step 4: Initialize conversation session (only for 'new' or 'iterate' modes)
|
||||
if ((mode === 'new' || mode === 'iterate') && agentType && effectiveAutoRunFolderPath) {
|
||||
// Only allow wizard for agents that support structured output
|
||||
const supportedWizardAgents: ToolType[] = ['claude', 'claude-code', 'codex'];
|
||||
if (
|
||||
(mode === 'new' || mode === 'iterate') &&
|
||||
agentType &&
|
||||
supportedWizardAgents.includes(agentType) &&
|
||||
effectiveAutoRunFolderPath
|
||||
) {
|
||||
const session = startInlineWizardConversation({
|
||||
mode,
|
||||
agentType,
|
||||
@@ -594,7 +601,7 @@ export function useInlineWizard(): UseInlineWizardReturn {
|
||||
goal: goal || undefined,
|
||||
existingDocs: docsWithContent.length > 0 ? docsWithContent : undefined,
|
||||
autoRunFolderPath: effectiveAutoRunFolderPath,
|
||||
sessionSshRemoteConfig,
|
||||
sessionSshRemoteConfig,
|
||||
});
|
||||
|
||||
// Store conversation session per-tab
|
||||
@@ -608,6 +615,19 @@ export function useInlineWizard(): UseInlineWizardReturn {
|
||||
existingDocsCount: docsWithContent.length,
|
||||
autoRunFolderPath: effectiveAutoRunFolderPath,
|
||||
});
|
||||
} else if (
|
||||
(mode === 'new' || mode === 'iterate') &&
|
||||
agentType &&
|
||||
!supportedWizardAgents.includes(agentType)
|
||||
) {
|
||||
// Agent not supported for wizard
|
||||
logger.warn(`Wizard not supported for agent type: ${agentType}`, '[InlineWizard]');
|
||||
setTabState(effectiveTabId, (prev) => ({
|
||||
...prev,
|
||||
isInitializing: false,
|
||||
error: `The inline wizard is not supported for ${agentType}. Please use Claude, Claude Code, or Codex.`,
|
||||
}));
|
||||
return; // Don't update state with parsed results
|
||||
}
|
||||
|
||||
// Update state with parsed results
|
||||
@@ -678,6 +698,9 @@ export function useInlineWizard(): UseInlineWizardReturn {
|
||||
*/
|
||||
const sendMessage = useCallback(
|
||||
async (content: string, callbacks?: ConversationCallbacks): Promise<void> => {
|
||||
// Only allow wizard for agents that support structured output
|
||||
const supportedWizardAgents: ToolType[] = ['claude', 'claude-code', 'codex'];
|
||||
|
||||
// Get the tab ID from the current state, ensure currentTabId is set for visibility
|
||||
const tabId = currentTabId || 'default';
|
||||
if (tabId !== currentTabId) {
|
||||
@@ -719,7 +742,12 @@ export function useInlineWizard(): UseInlineWizardReturn {
|
||||
currentState?.autoRunFolderPath ||
|
||||
(currentState?.projectPath ? getAutoRunFolderPath(currentState.projectPath) : null);
|
||||
|
||||
if (currentState?.mode === 'ask' && currentState.agentType && effectiveAutoRunFolderPath) {
|
||||
if (
|
||||
currentState?.mode === 'ask' &&
|
||||
currentState.agentType &&
|
||||
supportedWizardAgents.includes(currentState.agentType) &&
|
||||
effectiveAutoRunFolderPath
|
||||
) {
|
||||
console.log('[useInlineWizard] Auto-creating session for direct message in ask mode');
|
||||
session = startInlineWizardConversation({
|
||||
mode: 'new',
|
||||
@@ -729,7 +757,7 @@ export function useInlineWizard(): UseInlineWizardReturn {
|
||||
goal: currentState.goal || undefined,
|
||||
existingDocs: undefined,
|
||||
autoRunFolderPath: effectiveAutoRunFolderPath,
|
||||
sessionSshRemoteConfig: currentState.sessionSshRemoteConfig,
|
||||
sessionSshRemoteConfig: currentState.sessionSshRemoteConfig,
|
||||
});
|
||||
conversationSessionsMap.current.set(tabId, session);
|
||||
// Update mode to 'new' since we're proceeding with a new plan
|
||||
@@ -910,7 +938,7 @@ export function useInlineWizard(): UseInlineWizardReturn {
|
||||
goal: currentState.goal || undefined,
|
||||
existingDocs: undefined, // Will be loaded separately if needed
|
||||
autoRunFolderPath: effectiveAutoRunFolderPath,
|
||||
sessionSshRemoteConfig: currentState.sessionSshRemoteConfig,
|
||||
sessionSshRemoteConfig: currentState.sessionSshRemoteConfig,
|
||||
});
|
||||
|
||||
conversationSessionsMap.current.set(tabId, session);
|
||||
|
||||
@@ -499,6 +499,11 @@ function buildArgsForAgent(agent: any): string[] {
|
||||
// Add base args (if any)
|
||||
args.push(...(agent.args || []));
|
||||
|
||||
// Add read-only mode: '--agent plan'
|
||||
if (agent.readOnlyArgs) {
|
||||
args.push(...agent.readOnlyArgs);
|
||||
}
|
||||
|
||||
// Add JSON output: '--format json'
|
||||
if (agent.jsonOutputArgs) {
|
||||
args.push(...agent.jsonOutputArgs);
|
||||
@@ -707,10 +712,17 @@ export async function sendWizardMessage(
|
||||
}
|
||||
);
|
||||
|
||||
// Use the agent's resolved path if available, falling back to command name
|
||||
// This is critical for packaged Electron apps where PATH may not include agent locations
|
||||
const commandToUse = agent.path || agent.command;
|
||||
|
||||
// Spawn the agent process
|
||||
logger.info(`Spawning wizard agent process`, '[InlineWizardConversation]', {
|
||||
sessionId: session.sessionId,
|
||||
agentType: session.agentType,
|
||||
command: commandToUse,
|
||||
agentPath: agent.path,
|
||||
agentCommand: agent.command,
|
||||
cwd: session.directoryPath,
|
||||
historyLength: conversationHistory.length,
|
||||
});
|
||||
@@ -720,7 +732,7 @@ export async function sendWizardMessage(
|
||||
sessionId: session.sessionId,
|
||||
toolType: session.agentType,
|
||||
cwd: session.directoryPath,
|
||||
command: agent.command,
|
||||
command: commandToUse,
|
||||
args: argsForSpawn,
|
||||
prompt: fullPrompt,
|
||||
// Pass SSH config for remote execution
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/main/**/*"],
|
||||
"include": ["src/main/**/*", "src/shared/**/*", "src/types/**/*"],
|
||||
"exclude": ["node_modules", "dist", "release"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user