## CHANGES

- OpenCode now uses `-p` promptArgs for true YOLO auto-approve mode 🧨
- ProcessManager supports flag-based prompts via new `promptArgs` hook 🧩
- IPC spawn handler now forwards `promptArgs` and respects SSH prompt formatting 🧵
- SSH spawning now uses local home cwd, preventing remote-path ENOENT failures 🏠
- Group chat pipeline propagates `promptArgs` everywhere and logs detection 🔍
- Added regression tests ensuring OpenCode never falls back to `run` mode 🛡️
- Delete confirmations get clearer titles and improved ARIA labels for accessibility 
- Delete modals now show a trash icon in headers for better intent clarity 🗑️
- Document Graph now reads stats/content with SSH remote ID support 🗺️
- FilePreview layer registration stabilized to avoid re-register loops 🌀
This commit is contained in:
Pedram Amini
2026-01-05 21:11:21 -06:00
parent a43bfeb2ee
commit ca8587bba8
22 changed files with 303 additions and 59 deletions

View File

@@ -171,7 +171,7 @@ const AGENTS: AgentConfig[] = [
checkCommand: 'opencode --version',
/**
* Mirrors agent-detector.ts OpenCode definition:
* batchModePrefix: ['run']
* promptArgs: (prompt) => ['-p', prompt] // -p flag enables YOLO mode (auto-approve)
* jsonOutputArgs: ['--format', 'json']
*
* And process-manager.ts spawn() logic for images:
@@ -179,7 +179,6 @@ const AGENTS: AgentConfig[] = [
*/
buildArgs: (prompt: string, options?: { images?: string[] }) => {
const args = [
'run',
'--format', 'json',
];
@@ -193,8 +192,8 @@ const AGENTS: AgentConfig[] = [
throw new Error('OpenCode should not support stream-json input - capability misconfigured');
}
// Regular batch mode - prompt as CLI arg
return [...args, '--', prompt];
// OpenCode uses -p flag for prompt (enables YOLO mode with auto-approve)
return [...args, '-p', prompt];
},
parseResponse: (output: string) => {
const responses: string[] = [];

View File

@@ -465,7 +465,7 @@ const PROVIDERS: ProviderConfig[] = [
checkCommand: 'opencode --version',
/**
* Mirrors agent-detector.ts OpenCode definition:
* batchModePrefix: ['run']
* promptArgs: (prompt) => ['-p', prompt] // -p flag enables YOLO mode (auto-approve)
* jsonOutputArgs: ['--format', 'json']
*
* And process-manager.ts spawn() logic for images:
@@ -474,14 +474,12 @@ const PROVIDERS: ProviderConfig[] = [
*/
buildInitialArgs: (prompt: string, options?: { images?: string[] }) => {
// OpenCode arg order from process.ts IPC handler:
// 1. batchModePrefix: ['run']
// 2. base args: [] (empty for OpenCode)
// 3. jsonOutputArgs: ['--format', 'json']
// 4. Optional: --model provider/model (from OPENCODE_MODEL env var)
// 5. prompt via '--' separator (process-manager.ts)
// 1. base args: [] (empty for OpenCode)
// 2. jsonOutputArgs: ['--format', 'json']
// 3. Optional: --model provider/model (from OPENCODE_MODEL env var)
// 4. promptArgs: ['-p', prompt] (YOLO mode with auto-approve)
const args = [
'run',
'--format', 'json',
];
@@ -503,12 +501,11 @@ const PROVIDERS: ProviderConfig[] = [
throw new Error('OpenCode should not support stream-json input - capability misconfigured');
}
// Regular batch mode - prompt as CLI arg
return [...args, '--', prompt];
// OpenCode uses -p flag for prompt (enables YOLO mode with auto-approve)
return [...args, '-p', prompt];
},
buildResumeArgs: (sessionId: string, prompt: string) => {
const args = [
'run',
'--format', 'json',
];
@@ -518,7 +515,8 @@ const PROVIDERS: ProviderConfig[] = [
args.push('--model', model);
}
args.push('--session', sessionId, '--', prompt);
// -p flag for YOLO mode, --session for resume
args.push('--session', sessionId, '-p', prompt);
return args;
},
parseSessionId: (output: string) => {

View File

@@ -1115,6 +1115,65 @@ describe('agent-detector', () => {
});
});
describe('OpenCode YOLO mode configuration', () => {
it('should use promptArgs with -p flag for YOLO mode (not batchModePrefix with run)', async () => {
// This test ensures we never regress to using 'run' subcommand which does NOT auto-approve permissions
// The -p flag is required for YOLO mode (auto-approve all permissions)
mockExecFileNoThrow.mockImplementation(async (cmd, args) => {
if (args[0] === 'opencode') {
return { stdout: '/usr/bin/opencode\n', stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: '', exitCode: 1 };
});
const agents = await detector.detectAgents();
const opencode = agents.find(a => a.id === 'opencode');
expect(opencode).toBeDefined();
// CRITICAL: OpenCode must NOT use batchModePrefix with 'run' - it doesn't auto-approve permissions
expect(opencode?.batchModePrefix).toBeUndefined();
// CRITICAL: OpenCode MUST use promptArgs with -p flag for YOLO mode
expect(opencode?.promptArgs).toBeDefined();
expect(typeof opencode?.promptArgs).toBe('function');
// Verify promptArgs generates correct -p flag
const promptArgsResult = opencode?.promptArgs?.('test prompt');
expect(promptArgsResult).toEqual(['-p', 'test prompt']);
});
it('should NOT have noPromptSeparator since promptArgs handles prompt formatting', async () => {
mockExecFileNoThrow.mockImplementation(async (cmd, args) => {
if (args[0] === 'opencode') {
return { stdout: '/usr/bin/opencode\n', stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: '', exitCode: 1 };
});
const agents = await detector.detectAgents();
const opencode = agents.find(a => a.id === 'opencode');
// When using promptArgs, noPromptSeparator should be undefined or not needed
// since the prompt is passed via the -p flag, not as a positional argument
expect(opencode?.noPromptSeparator).toBeUndefined();
});
it('should have correct jsonOutputArgs for JSON streaming', async () => {
mockExecFileNoThrow.mockImplementation(async (cmd, args) => {
if (args[0] === 'opencode') {
return { stdout: '/usr/bin/opencode\n', stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: '', exitCode: 1 };
});
const agents = await detector.detectAgents();
const opencode = agents.find(a => a.id === 'opencode');
expect(opencode?.jsonOutputArgs).toEqual(['--format', 'json']);
});
});
describe('clearModelCache', () => {
beforeEach(async () => {
mockExecFileNoThrow.mockImplementation(async (cmd, args) => {

View File

@@ -347,6 +347,70 @@ describe('process IPC handlers', () => {
})
);
});
it('should pass promptArgs to spawn for agents that use flag-based prompts (like OpenCode -p)', async () => {
// This test ensures promptArgs is passed through to ProcessManager.spawn
// OpenCode uses promptArgs: (prompt) => ['-p', prompt] for YOLO mode
const mockPromptArgs = (prompt: string) => ['-p', prompt];
const mockAgent = {
id: 'opencode',
requiresPty: false,
promptArgs: mockPromptArgs,
};
mockAgentDetector.getAgent.mockResolvedValue(mockAgent);
mockProcessManager.spawn.mockReturnValue({ pid: 2001, success: true });
const handler = handlers.get('process:spawn');
await handler!({} as any, {
sessionId: 'session-opencode',
toolType: 'opencode',
cwd: '/test/project',
command: 'opencode',
args: ['--format', 'json'],
prompt: 'test prompt for opencode',
});
// Verify promptArgs function is passed to spawn
expect(mockProcessManager.spawn).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'session-opencode',
toolType: 'opencode',
promptArgs: mockPromptArgs,
})
);
});
it('should NOT pass promptArgs for agents that use positional prompts (like Claude)', async () => {
// Claude uses positional args with -- separator, not promptArgs
const mockAgent = {
id: 'claude-code',
requiresPty: false,
// Note: no promptArgs defined
};
mockAgentDetector.getAgent.mockResolvedValue(mockAgent);
mockProcessManager.spawn.mockReturnValue({ pid: 2002, success: true });
const handler = handlers.get('process:spawn');
await handler!({} as any, {
sessionId: 'session-claude',
toolType: 'claude-code',
cwd: '/test/project',
command: 'claude',
args: ['--print', '--verbose'],
prompt: 'test prompt for claude',
});
// Verify promptArgs is undefined for Claude
expect(mockProcessManager.spawn).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'session-claude',
toolType: 'claude-code',
promptArgs: undefined,
})
);
});
});
describe('process:write', () => {
@@ -978,5 +1042,47 @@ describe('process IPC handlers', () => {
const remoteCommandArg = spawnCall.args[spawnCall.args.length - 1];
expect(remoteCommandArg).toContain('/home/devuser/projects');
});
it('should use local home directory as cwd when spawning SSH (fixes ENOENT for remote-only paths)', async () => {
// This test verifies the fix for: spawn /usr/bin/ssh ENOENT
// The bug occurred because when session.cwd is a remote path (e.g., /home/user/project),
// that path doesn't exist locally, causing Node.js spawn() to fail with ENOENT.
// The fix uses os.homedir() as the local cwd when SSH is active.
const mockAgent = {
id: 'claude-code',
requiresPty: false,
};
mockAgentDetector.getAgent.mockResolvedValue(mockAgent);
mockSettingsStore.get.mockImplementation((key, defaultValue) => {
if (key === 'sshRemotes') return [mockSshRemote];
return defaultValue;
});
mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true });
const handler = handlers.get('process:spawn');
await handler!({} as any, {
sessionId: 'session-1',
toolType: 'claude-code',
cwd: '/home/remoteuser/remote-project', // Remote path that doesn't exist locally
command: 'claude',
args: ['--print'],
sessionSshRemoteConfig: {
enabled: true,
remoteId: 'remote-1',
},
});
// When using SSH, the local cwd should be user's home directory (via os.homedir())
// NOT the remote path which would cause ENOENT
const spawnCall = mockProcessManager.spawn.mock.calls[0][0];
expect(spawnCall.command).toBe('ssh');
// The cwd should be the local home directory, not the remote path
// We can't easily test the exact value of os.homedir() in a mock,
// but we verify it's NOT the remote path
expect(spawnCall.cwd).not.toBe('/home/remoteuser/remote-project');
// The remote path should be embedded in the SSH command args instead
expect(spawnCall.args.join(' ')).toContain('claude');
});
});
});

View File

@@ -20,6 +20,9 @@ import type { Theme } from '../../../renderer/types';
vi.mock('lucide-react', () => ({
X: () => <svg data-testid="x-icon" />,
AlertTriangle: () => <svg data-testid="alert-triangle-icon" />,
Trash2: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<svg data-testid="trash2-icon" className={className} style={style} />
),
}));
// Create a test theme
@@ -83,10 +86,23 @@ describe('ConfirmModal', () => {
/>
);
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
expect(screen.getByText('Confirm Delete')).toBeInTheDocument();
expect(screen.getByTestId('x-icon')).toBeInTheDocument();
});
it('renders trash icon in header', () => {
renderWithLayerStack(
<ConfirmModal
theme={testTheme}
message="Test"
onConfirm={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByTestId('trash2-icon')).toBeInTheDocument();
});
it('has correct ARIA attributes', () => {
renderWithLayerStack(
<ConfirmModal
@@ -99,7 +115,7 @@ describe('ConfirmModal', () => {
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveAttribute('aria-label', 'Confirm Action');
expect(dialog).toHaveAttribute('aria-label', 'Confirm Delete');
});
});
@@ -277,7 +293,7 @@ describe('ConfirmModal', () => {
/>
);
expect(screen.getByRole('heading', { name: 'Confirm Action' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Confirm Delete' })).toBeInTheDocument();
});
});
});

View File

@@ -1518,8 +1518,8 @@ describe('FileExplorerPanel', () => {
fireEvent.click(deleteButton);
});
// Modal now uses standardized "Confirm Action" title
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
// Modal now uses "Delete File" title
expect(screen.getByText('Delete File')).toBeInTheDocument();
// Check that the modal shows the file name in the confirmation message
expect(screen.getByText(/cannot be undone/)).toBeInTheDocument();
});
@@ -1561,8 +1561,8 @@ describe('FileExplorerPanel', () => {
fireEvent.click(deleteButton);
});
// Modal now uses standardized "Confirm Action" title
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
// Modal now uses "Delete Folder" title
expect(screen.getByText('Delete Folder')).toBeInTheDocument();
expect(screen.getByText(/5 files/)).toBeInTheDocument();
expect(screen.getByText(/2 subfolders/)).toBeInTheDocument();
});

View File

@@ -764,8 +764,7 @@ describe('HistoryDetailModal', () => {
fireEvent.click(screen.getByTitle('Delete this history entry'));
// Modal now uses standardized "Confirm Action" title
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
expect(screen.getByText('Delete History Entry')).toBeInTheDocument();
expect(screen.getByText(/Are you sure you want to delete/)).toBeInTheDocument();
});
@@ -810,6 +809,10 @@ describe('HistoryDetailModal', () => {
);
fireEvent.click(screen.getByTitle('Delete this history entry'));
// Verify delete confirmation is shown
expect(screen.getByText('Delete History Entry')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(mockOnDelete).not.toHaveBeenCalled();
@@ -847,6 +850,9 @@ describe('HistoryDetailModal', () => {
fireEvent.click(screen.getByTitle('Delete this history entry'));
// Verify delete confirmation is shown
expect(screen.getByText('Delete History Entry')).toBeInTheDocument();
// Find the delete confirmation modal backdrop and click it
const backdrops = document.querySelectorAll('.bg-black\\/60');
if (backdrops.length > 1) {
@@ -1307,8 +1313,8 @@ describe('HistoryDetailModal', () => {
const modalContent = container.querySelector('.w-\\[400px\\]');
if (modalContent) {
fireEvent.click(modalContent);
// Modal should still be open - title is now "Confirm Action"
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
// Modal should still be open
expect(screen.getByText('Delete History Entry')).toBeInTheDocument();
}
});
});

View File

@@ -18,6 +18,9 @@ vi.mock('lucide-react', () => ({
AlertTriangle: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<svg data-testid="alert-triangle-icon" className={className} style={style} />
),
Trash2: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<svg data-testid="trash2-icon" className={className} style={style} />
),
}));
// Create a mock theme for testing
@@ -77,7 +80,22 @@ describe('PlaybookDeleteConfirmModal', () => {
</TestWrapper>
);
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
expect(screen.getByText('Delete Playbook')).toBeInTheDocument();
});
it('renders trash icon in header', () => {
render(
<TestWrapper>
<PlaybookDeleteConfirmModal
theme={mockTheme}
playbookName="My Test Playbook"
onConfirm={mockOnConfirm}
onCancel={mockOnCancel}
/>
</TestWrapper>
);
expect(screen.getByTestId('trash2-icon')).toBeInTheDocument();
});
it('displays the playbook name in the message', () => {
@@ -154,7 +172,7 @@ describe('PlaybookDeleteConfirmModal', () => {
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveAttribute('aria-label', 'Confirm Action');
expect(dialog).toHaveAttribute('aria-label', 'Delete Playbook');
});
});
@@ -291,7 +309,7 @@ describe('PlaybookDeleteConfirmModal', () => {
</TestWrapper>
);
const title = screen.getByText('Confirm Action');
const title = screen.getByText('Delete Playbook');
expect(title).toHaveStyle({
color: mockTheme.colors.textMain,
});
@@ -474,7 +492,7 @@ describe('PlaybookDeleteConfirmModal', () => {
);
// Should still render the modal
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
expect(screen.getByText('Delete Playbook')).toBeInTheDocument();
});
});
});

View File

@@ -45,6 +45,7 @@ export interface AgentConfig {
yoloModeArgs?: string[]; // Args for YOLO/full-access mode (e.g., ['--dangerously-bypass-approvals-and-sandbox'])
workingDirArgs?: (dir: string) => string[]; // Function to build working directory args (e.g., ['-C', dir])
imageArgs?: (imagePath: string) => string[]; // Function to build image attachment args (e.g., ['-i', imagePath] for Codex)
promptArgs?: (prompt: string) => string[]; // Function to build prompt args (e.g., ['-p', prompt] for OpenCode)
noPromptSeparator?: boolean; // If true, don't add '--' before the prompt in batch mode (OpenCode doesn't support it)
}
@@ -119,18 +120,17 @@ const AGENT_DEFINITIONS: Omit<AgentConfig, 'available' | 'path' | 'capabilities'
name: 'OpenCode',
binaryName: 'opencode',
command: 'opencode',
args: [], // Base args (none for OpenCode - batch mode uses 'run' subcommand)
args: [], // Base args (none for OpenCode)
// OpenCode CLI argument builders
// Batch mode: opencode run --format json [--model provider/model] [--session <id>] [--agent plan] "prompt"
// Note: 'run' subcommand auto-approves all permissions (YOLO mode is implicit)
batchModePrefix: ['run'], // OpenCode uses 'run' subcommand for batch mode
// Batch mode uses -p flag: opencode -p "prompt" --format json [--model provider/model] [--session <id>] [--agent plan]
// The -p flag runs in non-interactive mode and auto-approves all permissions (YOLO mode).
// Note: The 'run' subcommand does NOT auto-approve - only -p does.
jsonOutputArgs: ['--format', 'json'], // JSON output format
resumeArgs: (sessionId: string) => ['--session', sessionId], // Resume with session ID
readOnlyArgs: ['--agent', 'plan'], // Read-only/plan mode
modelArgs: (modelId: string) => ['--model', modelId], // Model selection (e.g., 'ollama/qwen3:8b')
yoloModeArgs: ['run'], // 'run' subcommand auto-approves all permissions (YOLO mode is implicit)
imageArgs: (imagePath: string) => ['-f', imagePath], // Image/file attachment: opencode run -f /path/to/image.png
noPromptSeparator: true, // OpenCode doesn't support '--' before prompt (breaks yargs parsing)
promptArgs: (prompt: string) => ['-p', prompt], // -p flag enables non-interactive mode with auto-approve (YOLO mode)
imageArgs: (imagePath: string) => ['-f', imagePath], // Image/file attachment: opencode -p "prompt" -f /path/to/image.png
// Agent-specific configuration options shown in UI
configOptions: [
{

View File

@@ -167,10 +167,12 @@ export async function addParticipant(
prompt,
contextWindow: getContextWindowValue(agentConfig, agentConfigValues || {}),
customEnvVars: configResolution.effectiveCustomEnvVars ?? effectiveEnvVars,
promptArgs: agentConfig?.promptArgs,
noPromptSeparator: agentConfig?.noPromptSeparator,
});
console.log(`[GroupChat:Debug] Spawn result: ${JSON.stringify(result)}`);
console.log(`[GroupChat:Debug] promptArgs: ${agentConfig?.promptArgs ? 'defined' : 'undefined'}`);
console.log(`[GroupChat:Debug] noPromptSeparator: ${agentConfig?.noPromptSeparator ?? false}`);
if (!result.success) {

View File

@@ -35,6 +35,7 @@ export interface IProcessManager {
prompt?: string;
customEnvVars?: Record<string, string>;
contextWindow?: number;
promptArgs?: (prompt: string) => string[];
noPromptSeparator?: boolean;
}): { pid: number; success: boolean };

View File

@@ -437,11 +437,13 @@ ${message}`;
prompt: fullPrompt,
contextWindow: getContextWindowValue(agent, agentConfigValues),
customEnvVars: configResolution.effectiveCustomEnvVars ?? getCustomEnvVarsCallback?.(chat.moderatorAgentId),
promptArgs: agent.promptArgs,
noPromptSeparator: agent.noPromptSeparator,
});
console.log(`[GroupChat:Debug] Spawn result: ${JSON.stringify(spawnResult)}`);
console.log(`[GroupChat:Debug] Moderator process spawned successfully`);
console.log(`[GroupChat:Debug] promptArgs: ${agent.promptArgs ? 'defined' : 'undefined'}`);
console.log(`[GroupChat:Debug] noPromptSeparator: ${agent.noPromptSeparator ?? false}`);
console.log(`[GroupChat:Debug] =================================================`);
} catch (error) {
@@ -722,10 +724,12 @@ export async function routeModeratorResponse(
prompt: participantPrompt,
contextWindow: getContextWindowValue(agent, agentConfigValues),
customEnvVars: configResolution.effectiveCustomEnvVars ?? getCustomEnvVarsCallback?.(participant.agentId),
promptArgs: agent.promptArgs,
noPromptSeparator: agent.noPromptSeparator,
});
console.log(`[GroupChat:Debug] Spawn result for ${participantName}: ${JSON.stringify(spawnResult)}`);
console.log(`[GroupChat:Debug] promptArgs: ${agent.promptArgs ? 'defined' : 'undefined'}`);
console.log(`[GroupChat:Debug] noPromptSeparator: ${agent.noPromptSeparator ?? false}`);
// Track this participant as pending response
@@ -980,11 +984,13 @@ Review the agent responses above. Either:
prompt: synthesisPrompt,
contextWindow: getContextWindowValue(agent, agentConfigValues),
customEnvVars: configResolution.effectiveCustomEnvVars ?? getCustomEnvVarsCallback?.(chat.moderatorAgentId),
promptArgs: agent.promptArgs,
noPromptSeparator: agent.noPromptSeparator,
});
console.log(`[GroupChat:Debug] Synthesis spawn result: ${JSON.stringify(spawnResult)}`);
console.log(`[GroupChat:Debug] Synthesis moderator process spawned successfully`);
console.log(`[GroupChat:Debug] promptArgs: ${agent.promptArgs ? 'defined' : 'undefined'}`);
console.log(`[GroupChat:Debug] noPromptSeparator: ${agent.noPromptSeparator ?? false}`);
console.log(`[GroupChat:Debug] ================================================`);
} catch (error) {
@@ -1123,9 +1129,11 @@ export async function respawnParticipantWithRecovery(
prompt: fullPrompt,
contextWindow: getContextWindowValue(agent, agentConfigValues),
customEnvVars: configResolution.effectiveCustomEnvVars ?? getCustomEnvVarsCallback?.(participant.agentId),
promptArgs: agent.promptArgs,
noPromptSeparator: agent.noPromptSeparator,
});
console.log(`[GroupChat:Debug] Recovery spawn result: ${JSON.stringify(spawnResult)}`);
console.log(`[GroupChat:Debug] promptArgs: ${agent.promptArgs ? 'defined' : 'undefined'}`);
console.log(`[GroupChat:Debug] =============================================`);
}

View File

@@ -1,5 +1,6 @@
import { ipcMain, BrowserWindow } from 'electron';
import Store from 'electron-store';
import * as os from 'os';
import { ProcessManager } from '../../process-manager';
import { AgentDetector } from '../../agent-detector';
import { logger } from '../../utils/logger';
@@ -251,10 +252,12 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
// For SSH execution, we need to include the prompt in the args here
// because ProcessManager.spawn() won't add it (we pass prompt: undefined for SSH)
// The prompt must be added with the '--' separator (unless noPromptSeparator is true)
// Use promptArgs if available (e.g., OpenCode -p), otherwise use positional arg
let sshArgs = finalArgs;
if (config.prompt) {
if (agent?.noPromptSeparator) {
if (agent?.promptArgs) {
sshArgs = [...finalArgs, ...agent.promptArgs(config.prompt)];
} else if (agent?.noPromptSeparator) {
sshArgs = [...finalArgs, config.prompt];
} else {
sshArgs = [...finalArgs, '--', config.prompt];
@@ -293,6 +296,10 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
...config,
command: commandToSpawn,
args: argsToSpawn,
// When using SSH, use user's home directory as local cwd
// The remote working directory is embedded in the SSH command itself
// This fixes ENOENT errors when session.cwd is a remote-only path
cwd: sshRemoteUsed ? os.homedir() : config.cwd,
// When using SSH, disable PTY (SSH provides its own terminal handling)
// and env vars are passed via the remote command string
requiresPty: sshRemoteUsed ? false : agent?.requiresPty,
@@ -306,7 +313,8 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
// When using SSH, env vars are passed in the remote command string, not locally
customEnvVars: sshRemoteUsed ? undefined : effectiveCustomEnvVars,
imageArgs: agent?.imageArgs, // Function to build image CLI args (for Codex, OpenCode)
noPromptSeparator: agent?.noPromptSeparator, // OpenCode doesn't support '--' before prompt
promptArgs: agent?.promptArgs, // Function to build prompt args (e.g., ['-p', prompt] for OpenCode)
noPromptSeparator: agent?.noPromptSeparator, // Some agents don't support '--' before prompt
// Stats tracking: use cwd as projectPath if not explicitly provided
projectPath: config.cwd,
// SSH remote context (for SSH-specific error messages)

View File

@@ -63,6 +63,7 @@ interface ProcessConfig {
shellEnvVars?: Record<string, string>; // Environment variables for shell sessions
images?: string[]; // Base64 data URLs for images (passed via stream-json input or file args)
imageArgs?: (imagePath: string) => string[]; // Function to build image CLI args (e.g., ['-i', path] for Codex)
promptArgs?: (prompt: string) => string[]; // Function to build prompt args (e.g., ['-p', prompt] for OpenCode)
contextWindow?: number; // Configured context window size (0 or undefined = not configured, hide UI)
customEnvVars?: Record<string, string>; // Custom environment variables from user configuration
noPromptSeparator?: boolean; // If true, don't add '--' before the prompt (e.g., OpenCode doesn't support it)
@@ -311,13 +312,14 @@ export class ProcessManager extends EventEmitter {
* Spawn a new process for a session
*/
spawn(config: ProcessConfig): { pid: number; success: boolean } {
const { sessionId, toolType, cwd, command, args, requiresPty, prompt, shell, shellArgs, shellEnvVars, images, imageArgs, contextWindow, customEnvVars, noPromptSeparator } = config;
const { sessionId, toolType, cwd, command, args, requiresPty, prompt, shell, shellArgs, shellEnvVars, images, imageArgs, promptArgs, contextWindow, customEnvVars, noPromptSeparator } = config;
// Detect Windows early for logging decisions throughout the function
const isWindows = process.platform === 'win32';
// For batch mode with images, use stream-json mode and send message via stdin
// For batch mode without images, append prompt to args with -- separator (unless noPromptSeparator is true)
// For agents with promptArgs (like OpenCode -p), use the promptArgs function to build prompt CLI args
const hasImages = images && images.length > 0;
const capabilities = getAgentCapabilities(toolType);
let finalArgs: string[];
@@ -339,8 +341,10 @@ export class ProcessManager extends EventEmitter {
finalArgs = [...finalArgs, ...imageArgs(tempPath)];
}
}
// Add the prompt at the end (with or without -- separator)
if (noPromptSeparator) {
// Add the prompt using promptArgs if available, otherwise as positional arg
if (promptArgs) {
finalArgs = [...finalArgs, ...promptArgs(prompt)];
} else if (noPromptSeparator) {
finalArgs = [...finalArgs, prompt];
} else {
finalArgs = [...finalArgs, '--', prompt];
@@ -352,9 +356,11 @@ export class ProcessManager extends EventEmitter {
});
} else if (prompt) {
// Regular batch mode - prompt as CLI arg
// The -- ensures prompt is treated as positional arg, not a flag (even if it starts with --)
// Some agents (e.g., OpenCode) don't support the -- separator
if (noPromptSeparator) {
// If agent has promptArgs (e.g., OpenCode -p), use that to build the prompt CLI args
// Otherwise, use the -- separator to treat prompt as positional arg (unless noPromptSeparator)
if (promptArgs) {
finalArgs = [...args, ...promptArgs(prompt)];
} else if (noPromptSeparator) {
finalArgs = [...args, prompt];
} else {
finalArgs = [...args, '--', prompt];

View File

@@ -31,6 +31,7 @@ export interface GroomingProcessManager {
command: string;
args: string[];
prompt?: string;
promptArgs?: (prompt: string) => string[];
noPromptSeparator?: boolean;
}): { pid: number; success?: boolean } | null;
on(event: string, handler: (...args: unknown[]) => void): void;
@@ -285,6 +286,7 @@ export async function groomContext(
command: agent.command,
args: finalArgs,
prompt: prompt, // Triggers batch mode (no PTY)
promptArgs: agent.promptArgs, // For agents using flag-based prompt (e.g., OpenCode -p)
noPromptSeparator: agent.noPromptSeparator,
});

View File

@@ -10570,11 +10570,15 @@ You are taking over this conversation. Based on the context above, provide a bri
ghCliAvailable={ghCliAvailable}
onPublishGist={() => setGistPublishModalOpen(true)}
onOpenInGraph={() => {
if (previewFile && activeSession?.fullPath) {
if (previewFile && activeSession) {
// Use the same rootPath that DocumentGraphView will use
const graphRootPath = activeSession.projectRoot || activeSession.cwd || '';
// Compute relative path from the preview file
const relativePath = previewFile.path.startsWith(activeSession.fullPath)
? previewFile.path.slice(activeSession.fullPath.length + 1)
: previewFile.name;
const relativePath = previewFile.path.startsWith(graphRootPath + '/')
? previewFile.path.slice(graphRootPath.length + 1)
: previewFile.path.startsWith(graphRootPath)
? previewFile.path.slice(graphRootPath.length + 1)
: previewFile.name;
setGraphFocusFilePath(relativePath);
setLastGraphFocusFilePath(relativePath); // Track for "Last Document Graph" in command palette
setIsGraphViewOpen(true);

View File

@@ -612,6 +612,7 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
textareaRef,
pushUndoState,
lastUndoSnapshotRef,
sshRemoteId,
});
// Switch mode with scroll position synchronization

View File

@@ -636,7 +636,7 @@ export function DocumentGraphView({
const fullPath = `${rootPath}/${selectedNode.filePath}`;
// Load file stats (created/modified dates)
window.maestro.fs.stat(fullPath)
window.maestro.fs.stat(fullPath, sshRemoteId)
.then(stats => {
setSelectedNodeStats({
createdAt: stats.createdAt ? new Date(stats.createdAt) : null,
@@ -648,7 +648,7 @@ export function DocumentGraphView({
});
// Load file content to count tasks
window.maestro.fs.readFile(fullPath)
window.maestro.fs.readFile(fullPath, sshRemoteId)
.then(content => {
const tasks = countMarkdownTasks(content);
setSelectedNodeTasks(tasks.total > 0 ? tasks : null);
@@ -656,7 +656,7 @@ export function DocumentGraphView({
.catch(() => {
setSelectedNodeTasks(null);
});
}, [selectedNode, rootPath]);
}, [selectedNode, rootPath, sshRemoteId]);
/**
* Handle node double-click - expand to show outgoing links (fan out)

View File

@@ -1229,6 +1229,7 @@ async function parseFileWithSsh(rootPath: string, relativePath: string, sshRemot
// Get file stats
const stat = await window.maestro.fs.stat(fullPath, sshRemoteId);
if (!stat) {
console.warn(`[DocumentGraph] parseFileWithSsh: stat returned null for ${fullPath}`);
return null;
}
const fileSize = stat.size ?? 0;
@@ -1246,6 +1247,7 @@ async function parseFileWithSsh(rootPath: string, relativePath: string, sshRemot
// Read file content
const content = await window.maestro.fs.readFile(fullPath, sshRemoteId);
if (content === null || content === undefined) {
console.warn(`[DocumentGraph] parseFileWithSsh: readFile returned null for ${fullPath}`);
return null;
}

View File

@@ -604,7 +604,7 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
// Fetch file stats when file changes
useEffect(() => {
if (file?.path) {
window.maestro.fs.stat(file.path)
window.maestro.fs.stat(file.path, sshRemoteId)
.then(stats => setFileStats({
size: stats.size,
createdAt: stats.createdAt,
@@ -615,7 +615,7 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
setFileStats(null);
});
}
}, [file?.path]);
}, [file?.path, sshRemoteId]);
// Count tokens when file content changes (skip for images and binary files)
useEffect(() => {
@@ -741,7 +741,10 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
}
}, [searchOpen, hasChanges, onClose]);
// Register layer on mount
// Register layer on mount - only register once, use updateLayerHandler for handler changes
// Note: handleEscapeRequest is intentionally NOT in the dependency array to prevent
// infinite re-registration loops when its dependencies (hasChanges, searchOpen) change.
// The subsequent useEffect with updateLayerHandler handles keeping the handler current.
useEffect(() => {
layerIdRef.current = registerLayer({
type: 'overlay',
@@ -759,7 +762,8 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
unregisterLayer(layerIdRef.current);
}
};
}, [registerLayer, unregisterLayer, handleEscapeRequest]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [registerLayer, unregisterLayer]);
// Update handler when dependencies change
useEffect(() => {

View File

@@ -1120,6 +1120,7 @@ export const MainPanel = React.memo(forwardRef<MainPanelHandle, MainPanelProps>(
ghCliAvailable={props.ghCliAvailable}
onPublishGist={props.onPublishGist}
onOpenInGraph={props.onOpenInGraph}
sshRemoteId={activeSession?.sshRemoteId || activeSession?.sessionSshRemoteConfig?.remoteId || undefined}
/>
</div>
) : (

View File

@@ -28,6 +28,8 @@ export interface UseAutoRunImageHandlingDeps {
pushUndoState: () => void;
/** Ref to last snapshotted content */
lastUndoSnapshotRef: React.MutableRefObject<string>;
/** SSH remote ID for remote file operations */
sshRemoteId?: string;
}
/**
@@ -127,6 +129,7 @@ export function useAutoRunImageHandling({
textareaRef,
pushUndoState,
lastUndoSnapshotRef,
sshRemoteId,
}: UseAutoRunImageHandlingDeps): UseAutoRunImageHandlingReturn {
// Attachment state
const [attachmentsList, setAttachmentsList] = useState<string[]>([]);
@@ -159,7 +162,7 @@ export function useAutoRunImageHandling({
// Load previews for existing images
result.images.forEach((img: { filename: string; relativePath: string }) => {
const absolutePath = `${folderPath}/${img.relativePath}`;
window.maestro.fs.readFile(absolutePath).then(dataUrl => {
window.maestro.fs.readFile(absolutePath, sshRemoteId).then(dataUrl => {
if (isStale) return;
if (dataUrl.startsWith('data:')) {
setAttachmentPreviews(prev => new Map(prev).set(img.relativePath, dataUrl));
@@ -187,7 +190,7 @@ export function useAutoRunImageHandling({
setAttachmentsList([]);
setAttachmentPreviews(new Map());
}
}, [folderPath, selectedFile]);
}, [folderPath, selectedFile, sshRemoteId]);
// Handle image paste
const handlePaste = useCallback(async (e: React.ClipboardEvent) => {