mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## 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:
@@ -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[] = [];
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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] =============================================`);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -612,6 +612,7 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
|
||||
textareaRef,
|
||||
pushUndoState,
|
||||
lastUndoSnapshotRef,
|
||||
sshRemoteId,
|
||||
});
|
||||
|
||||
// Switch mode with scroll position synchronization
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user