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:
chr1syy
2026-01-31 22:02:42 +01:00
committed by Pedram Amini
parent 795c37c20f
commit 565636674e
15 changed files with 327 additions and 124 deletions

View File

@@ -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 () => {

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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();

View File

@@ -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

View File

@@ -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)

View File

@@ -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', {

View File

@@ -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;
}
/**

View File

@@ -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;

View File

@@ -241,15 +241,15 @@ export function NewInstanceModal({
if (sshRemoteId) {
const connectionErrors = detectedAgents
.filter((a: AgentConfig) => !a.hidden)
.filter((a: any) => a.error)
.map((a: any) => a.error);
const allHaveErrors =
connectionErrors.length > 0 &&
detectedAgents
.filter((a: AgentConfig) => !a.hidden)
.every((a: any) => a.error || !a.available);
if (allHaveErrors && connectionErrors.length > 0) {
@@ -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;
});
}}
/>
)}

View File

@@ -388,7 +388,7 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
const visibleAgents = agents.filter((a: AgentConfig) => !a.hidden);
// Check if all agents have connection errors (indicates SSH connection failure)
const connectionErrors = visibleAgents
.filter((a: any) => a.error)
.map((a: any) => a.error);
@@ -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>

View File

@@ -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'

View File

@@ -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);

View File

@@ -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

View File

@@ -13,6 +13,6 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/main/**/*"],
"include": ["src/main/**/*", "src/shared/**/*", "src/types/**/*"],
"exclude": ["node_modules", "dist", "release"]
}