diff --git a/package-lock.json b/package-lock.json index 6e45af4c78..73272eb609 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.16.19-RC", + "version": "0.16.20-RC", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.16.19-RC", + "version": "0.16.20-RC", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { diff --git a/src/__tests__/cli/commands/auto-run.test.ts b/src/__tests__/cli/commands/auto-run.test.ts index 9790818151..777274d5ca 100644 --- a/src/__tests__/cli/commands/auto-run.test.ts +++ b/src/__tests__/cli/commands/auto-run.test.ts @@ -24,8 +24,13 @@ vi.mock('../../../cli/services/maestro-client', () => ({ resolveTargetSessionId: vi.fn(), })); +vi.mock('../../../cli/services/storage', () => ({ + readSessions: vi.fn(), +})); + import { autoRun } from '../../../cli/commands/auto-run'; import { withMaestroClient, resolveTargetSessionId } from '../../../cli/services/maestro-client'; +import { readSessions } from '../../../cli/services/storage'; import { existsSync } from 'fs'; describe('auto-run command', () => { @@ -38,6 +43,24 @@ describe('auto-run command', () => { consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + vi.mocked(readSessions).mockReturnValue([ + { + id: 'agent-123', + name: 'Agent', + toolType: 'claude-code', + cwd: '/project', + projectRoot: '/project', + autoRunFolderPath: '/path/to', + }, + { + id: 'full-agent-uuid-123', + name: 'Full Agent', + toolType: 'claude-code', + cwd: '/project', + projectRoot: '/project', + autoRunFolderPath: '/path/to', + }, + ]); }); it('should configure auto-run with valid document paths', async () => { @@ -62,6 +85,65 @@ describe('auto-run command', () => { expect(processExitSpy).not.toHaveBeenCalled(); }); + it('should send Auto Run folder-relative document filenames', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveTargetSessionId).mockReturnValue('agent-123'); + + let sentMessage: Record | undefined; + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const mockClient = { + sendCommand: vi.fn().mockImplementation((msg) => { + sentMessage = msg; + return Promise.resolve({ + type: 'configure_auto_run_result', + success: true, + }); + }), + }; + return action(mockClient as never); + }); + + await autoRun(['/path/to/doc.md', '/path/to/nested/step.md'], { agent: 'agent-123' }); + + const sentDocs = sentMessage!.documents as Array<{ filename: string }>; + expect(sentDocs.map((doc) => doc.filename)).toEqual(['doc.md', 'nested/step.md']); + }); + + it('should reject documents outside the selected Auto Run folder', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveTargetSessionId).mockReturnValue('agent-123'); + + await autoRun(['/other/doc.md'], { agent: 'agent-123' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("File must be inside the selected agent's Auto Run folder") + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + expect(withMaestroClient).not.toHaveBeenCalled(); + }); + + it('should reject when the selected agent has no Auto Run folder configured', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveTargetSessionId).mockReturnValue('agent-123'); + vi.mocked(readSessions).mockReturnValue([ + { + id: 'agent-123', + name: 'Agent', + toolType: 'claude-code', + cwd: '/project', + projectRoot: '/project', + }, + ]); + + await autoRun(['/path/to/doc.md'], { agent: 'agent-123' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Selected agent has no Auto Run folder configured') + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + expect(withMaestroClient).not.toHaveBeenCalled(); + }); + it('should error with no documents', async () => { await autoRun([], {}); diff --git a/src/__tests__/cli/commands/refresh-auto-run.test.ts b/src/__tests__/cli/commands/refresh-auto-run.test.ts new file mode 100644 index 0000000000..7af5e7bc86 --- /dev/null +++ b/src/__tests__/cli/commands/refresh-auto-run.test.ts @@ -0,0 +1,104 @@ +/** + * @file refresh-auto-run.test.ts + * @description Tests for the refresh-auto-run CLI command + */ + +import { describe, it, expect, vi, beforeEach, type MockInstance } from 'vitest'; + +vi.mock('../../../cli/services/maestro-client', () => ({ + resolveTargetSessionId: vi.fn(), + withMaestroClient: vi.fn(), +})); + +vi.mock('../../../cli/output/formatter', () => ({ + formatError: vi.fn((message: string) => `Error: ${message}`), +})); + +import { refreshAutoRun } from '../../../cli/commands/refresh-auto-run'; +import { resolveTargetSessionId, withMaestroClient } from '../../../cli/services/maestro-client'; + +describe('refresh-auto-run command', () => { + let consoleSpy: MockInstance; + let consoleErrorSpy: MockInstance; + let processExitSpy: MockInstance; + + beforeEach(() => { + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + vi.mocked(resolveTargetSessionId).mockReturnValue('target-session'); + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const client = { + sendCommand: vi.fn().mockResolvedValue({ + type: 'refresh_auto_run_docs_result', + success: true, + }), + }; + return action(client as never); + }); + }); + + it('refreshes Auto Run documents with an explicit agent', async () => { + await refreshAutoRun({ agent: 'target-agent' }); + + expect(resolveTargetSessionId).toHaveBeenCalledWith('target-agent'); + expect(withMaestroClient).toHaveBeenCalledTimes(1); + const action = vi.mocked(withMaestroClient).mock.calls[0][0]; + const sendCommand = vi.fn().mockResolvedValue({ + type: 'refresh_auto_run_docs_result', + success: true, + }); + await action({ sendCommand } as never); + expect(sendCommand).toHaveBeenCalledWith( + { type: 'refresh_auto_run_docs', sessionId: 'target-session' }, + 'refresh_auto_run_docs_result' + ); + expect(consoleSpy).toHaveBeenCalledWith('Auto Run documents refreshed'); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('refreshes Auto Run documents with the resolved default agent', async () => { + vi.mocked(resolveTargetSessionId).mockReturnValue('resolved-session'); + + await refreshAutoRun({}); + + const action = vi.mocked(withMaestroClient).mock.calls[0][0]; + const sendCommand = vi.fn().mockResolvedValue({ + type: 'refresh_auto_run_docs_result', + success: true, + }); + await action({ sendCommand } as never); + expect(sendCommand).toHaveBeenCalledWith( + { type: 'refresh_auto_run_docs', sessionId: 'resolved-session' }, + 'refresh_auto_run_docs_result' + ); + }); + + it('exits with an error when Maestro is not reachable', async () => { + vi.mocked(withMaestroClient).mockRejectedValue(new Error('Maestro desktop app is not running')); + + await refreshAutoRun({ agent: 'target-agent' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error: Maestro desktop app is not running'); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('exits with an error when Maestro rejects the refresh', async () => { + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const client = { + sendCommand: vi.fn().mockResolvedValue({ + type: 'refresh_auto_run_docs_result', + success: false, + error: 'Session not found', + }), + }; + return action(client as never); + }); + + await refreshAutoRun({ agent: 'target-agent' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error: Session not found'); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/__tests__/cli/commands/refresh-files.test.ts b/src/__tests__/cli/commands/refresh-files.test.ts new file mode 100644 index 0000000000..cad99419da --- /dev/null +++ b/src/__tests__/cli/commands/refresh-files.test.ts @@ -0,0 +1,104 @@ +/** + * @file refresh-files.test.ts + * @description Tests for the refresh-files CLI command + */ + +import { describe, it, expect, vi, beforeEach, type MockInstance } from 'vitest'; + +vi.mock('../../../cli/services/maestro-client', () => ({ + resolveTargetSessionId: vi.fn(), + withMaestroClient: vi.fn(), +})); + +vi.mock('../../../cli/output/formatter', () => ({ + formatError: vi.fn((message: string) => `Error: ${message}`), +})); + +import { refreshFiles } from '../../../cli/commands/refresh-files'; +import { resolveTargetSessionId, withMaestroClient } from '../../../cli/services/maestro-client'; + +describe('refresh-files command', () => { + let consoleSpy: MockInstance; + let consoleErrorSpy: MockInstance; + let processExitSpy: MockInstance; + + beforeEach(() => { + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + vi.mocked(resolveTargetSessionId).mockReturnValue('target-session'); + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const client = { + sendCommand: vi.fn().mockResolvedValue({ + type: 'refresh_file_tree_result', + success: true, + }), + }; + return action(client as never); + }); + }); + + it('refreshes the file tree with an explicit agent', async () => { + await refreshFiles({ agent: 'target-agent' }); + + expect(resolveTargetSessionId).toHaveBeenCalledWith('target-agent'); + expect(withMaestroClient).toHaveBeenCalledTimes(1); + const action = vi.mocked(withMaestroClient).mock.calls[0][0]; + const sendCommand = vi.fn().mockResolvedValue({ + type: 'refresh_file_tree_result', + success: true, + }); + await action({ sendCommand } as never); + expect(sendCommand).toHaveBeenCalledWith( + { type: 'refresh_file_tree', sessionId: 'target-session' }, + 'refresh_file_tree_result' + ); + expect(consoleSpy).toHaveBeenCalledWith('File tree refreshed'); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('refreshes the file tree with the resolved default agent', async () => { + vi.mocked(resolveTargetSessionId).mockReturnValue('resolved-session'); + + await refreshFiles({}); + + const action = vi.mocked(withMaestroClient).mock.calls[0][0]; + const sendCommand = vi.fn().mockResolvedValue({ + type: 'refresh_file_tree_result', + success: true, + }); + await action({ sendCommand } as never); + expect(sendCommand).toHaveBeenCalledWith( + { type: 'refresh_file_tree', sessionId: 'resolved-session' }, + 'refresh_file_tree_result' + ); + }); + + it('exits with an error when Maestro is not reachable', async () => { + vi.mocked(withMaestroClient).mockRejectedValue(new Error('Maestro desktop app is not running')); + + await refreshFiles({ agent: 'target-agent' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error: Maestro desktop app is not running'); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('exits with an error when Maestro rejects the refresh', async () => { + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const client = { + sendCommand: vi.fn().mockResolvedValue({ + type: 'refresh_file_tree_result', + success: false, + error: 'Session not found', + }), + }; + return action(client as never); + }); + + await refreshFiles({ agent: 'target-agent' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error: Session not found'); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/__tests__/cli/commands/send.test.ts b/src/__tests__/cli/commands/send.test.ts index 14acdf2791..092796595e 100644 --- a/src/__tests__/cli/commands/send.test.ts +++ b/src/__tests__/cli/commands/send.test.ts @@ -62,6 +62,7 @@ import { prepareMaestroSystemPromptCli } from '../../../cli/services/system-prom describe('send command', () => { let consoleSpy: MockInstance; + let consoleErrorSpy: MockInstance; let processExitSpy: MockInstance; const mockAgent = (overrides: Partial = {}): SessionInfo => ({ @@ -76,6 +77,7 @@ describe('send command', () => { beforeEach(() => { vi.clearAllMocks(); consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); // Default: system-prompt builder returns undefined so existing assertions // that don't include `appendSystemPrompt` keep passing (vitest treats @@ -409,4 +411,62 @@ describe('send command', () => { expect(output.success).toBe(true); expect(output.usage).toBeNull(); }); + + it('should focus the Maestro session tab when --tab is provided', async () => { + vi.mocked(resolveAgentId).mockReturnValue('agent-abc-123'); + vi.mocked(getSessionById).mockReturnValue(mockAgent()); + vi.mocked(detectAgent).mockResolvedValue({ available: true, path: '/usr/bin/claude' }); + vi.mocked(spawnAgent).mockResolvedValue({ + success: true, + response: 'OK', + agentSessionId: 'session-no-stats', + }); + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + await action({ + sendCommand: vi.fn().mockResolvedValue({ + type: 'select_session_result', + success: true, + sessionId: 'agent-abc-123', + }), + } as never); + }); + + await send('agent-abc', 'Simple message', { tab: true }); + + expect(withMaestroClient).toHaveBeenCalledTimes(1); + const action = vi.mocked(withMaestroClient).mock.calls[0][0]; + const sendCommand = vi.fn().mockResolvedValue({ + type: 'select_session_result', + success: true, + sessionId: 'agent-abc-123', + }); + await action({ sendCommand } as never); + expect(sendCommand).toHaveBeenCalledWith( + { type: 'select_session', sessionId: 'agent-abc-123', focus: true }, + 'select_session_result' + ); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should warn but not fail when --tab cannot reach Maestro desktop', async () => { + vi.mocked(resolveAgentId).mockReturnValue('agent-abc-123'); + vi.mocked(getSessionById).mockReturnValue(mockAgent()); + vi.mocked(detectAgent).mockResolvedValue({ available: true, path: '/usr/bin/claude' }); + vi.mocked(spawnAgent).mockResolvedValue({ + success: true, + response: 'OK', + agentSessionId: 'session-no-stats', + }); + vi.mocked(withMaestroClient).mockRejectedValue(new Error('Maestro desktop app is not running')); + + await send('agent-abc', 'Simple message', { tab: true }); + + const output = JSON.parse(consoleSpy.mock.calls[0][0]); + expect(output.success).toBe(true); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Warning: Could not focus session tab in Maestro desktop (app may not be running)' + ); + expect(processExitSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/__tests__/cli/commands/status.test.ts b/src/__tests__/cli/commands/status.test.ts new file mode 100644 index 0000000000..19399a26eb --- /dev/null +++ b/src/__tests__/cli/commands/status.test.ts @@ -0,0 +1,122 @@ +/** + * @file status.test.ts + * @description Tests for the status CLI command + */ + +import { describe, it, expect, vi, beforeEach, type MockInstance } from 'vitest'; + +vi.mock('../../../shared/cli-server-discovery', () => ({ + readCliServerInfo: vi.fn(), + isCliServerRunning: vi.fn(), +})); + +vi.mock('../../../cli/services/maestro-client', () => ({ + withMaestroClient: vi.fn(), +})); + +vi.mock('../../../cli/output/formatter', () => ({ + formatError: vi.fn((message: string) => `Error: ${message}`), +})); + +import { status } from '../../../cli/commands/status'; +import { readCliServerInfo, isCliServerRunning } from '../../../shared/cli-server-discovery'; +import { withMaestroClient } from '../../../cli/services/maestro-client'; + +describe('status command', () => { + let consoleSpy: MockInstance; + let consoleErrorSpy: MockInstance; + let processExitSpy: MockInstance; + + beforeEach(() => { + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + vi.mocked(readCliServerInfo).mockReturnValue({ + port: 47321, + token: 'test-token', + pid: 1234, + startedAt: 1710000000000, + }); + vi.mocked(isCliServerRunning).mockReturnValue(true); + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const client = { + sendCommand: vi + .fn() + .mockResolvedValueOnce({ type: 'pong' }) + .mockResolvedValueOnce({ + type: 'sessions_list', + sessions: [{ id: 'session-1' }, { id: 'session-2' }], + }), + }; + return action(client as never); + }); + }); + + it('prints not running when the discovery file is missing', async () => { + vi.mocked(readCliServerInfo).mockReturnValue(null); + + await status(); + + expect(consoleSpy).toHaveBeenCalledWith('Maestro desktop app is not running'); + expect(isCliServerRunning).not.toHaveBeenCalled(); + expect(withMaestroClient).not.toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('prints stale discovery when the PID is not running', async () => { + vi.mocked(isCliServerRunning).mockReturnValue(false); + + await status(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Maestro discovery file is stale (app may have crashed)' + ); + expect(withMaestroClient).not.toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('pings Maestro and prints the port with agent count', async () => { + await status(); + + expect(withMaestroClient).toHaveBeenCalledTimes(1); + const action = vi.mocked(withMaestroClient).mock.calls[0][0]; + const sendCommand = vi + .fn() + .mockResolvedValueOnce({ type: 'pong' }) + .mockResolvedValueOnce({ + type: 'sessions_list', + sessions: [{ id: 'session-1' }, { id: 'session-2' }, { id: 'session-3' }], + }); + await action({ sendCommand } as never); + expect(sendCommand).toHaveBeenNthCalledWith(1, { type: 'ping' }, 'pong'); + expect(sendCommand).toHaveBeenNthCalledWith(2, { type: 'get_sessions' }, 'sessions_list'); + expect(consoleSpy).toHaveBeenCalledWith('Maestro is running on port 47321 with 2 agents'); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('treats a missing sessions array as zero sessions', async () => { + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const client = { + sendCommand: vi + .fn() + .mockResolvedValueOnce({ type: 'pong' }) + .mockResolvedValueOnce({ type: 'sessions_list' }), + }; + return action(client as never); + }); + + await status(); + + expect(consoleSpy).toHaveBeenCalledWith('Maestro is running on port 47321 with 0 agents'); + }); + + it('exits with an error when Maestro cannot be reached', async () => { + vi.mocked(withMaestroClient).mockRejectedValue(new Error('Connection refused')); + + await status(); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error: Connection refused'); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/__tests__/cli/services/maestro-client-session.test.ts b/src/__tests__/cli/services/maestro-client-session.test.ts new file mode 100644 index 0000000000..6d3bba10ff --- /dev/null +++ b/src/__tests__/cli/services/maestro-client-session.test.ts @@ -0,0 +1,58 @@ +/** + * @file maestro-client-session.test.ts + * @description Tests for CLI Maestro session resolution helpers + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { SessionInfo } from '../../../shared/types'; + +vi.mock('../../../cli/services/storage', () => ({ + getSessionById: vi.fn(), + readSessions: vi.fn(), + readSettings: vi.fn(), +})); + +import { resolveSessionId } from '../../../cli/services/maestro-client'; +import { getSessionById, readSessions, readSettings } from '../../../cli/services/storage'; + +describe('resolveSessionId', () => { + const mockSession = (overrides: Partial = {}): SessionInfo => ({ + id: 'session-123', + name: 'Test Agent', + toolType: 'claude-code', + cwd: '/path/to/project', + projectRoot: '/path/to/project', + ...overrides, + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(readSettings).mockReturnValue({}); + vi.mocked(readSessions).mockReturnValue([]); + vi.mocked(getSessionById).mockReturnValue(undefined); + }); + + it('uses an explicit session when provided', () => { + expect(resolveSessionId({ session: 'target-session' })).toBe('target-session'); + expect(readSettings).not.toHaveBeenCalled(); + }); + + it('uses the active session from settings when it exists', () => { + vi.mocked(readSettings).mockReturnValue({ activeSessionId: 'active-session' }); + vi.mocked(getSessionById).mockReturnValue(mockSession({ id: 'active-session' })); + + expect(resolveSessionId()).toBe('active-session'); + }); + + it('falls back to the first stored session when there is no active session', () => { + vi.mocked(readSessions).mockReturnValue([mockSession({ id: 'first-session' })]); + + expect(resolveSessionId()).toBe('first-session'); + }); + + it('throws when no session can be resolved', () => { + expect(() => resolveSessionId()).toThrow( + 'No Maestro sessions found. Pass --session to target a specific session.' + ); + }); +}); diff --git a/src/__tests__/cli/services/maestro-client.test.ts b/src/__tests__/cli/services/maestro-client.test.ts index 4c27196188..2112fdce8c 100644 --- a/src/__tests__/cli/services/maestro-client.test.ts +++ b/src/__tests__/cli/services/maestro-client.test.ts @@ -44,6 +44,9 @@ vi.mock('../../../shared/cli-server-discovery', () => ({ vi.mock('../../../cli/services/storage', () => ({ readSessions: vi.fn(), + readSettings: vi.fn(), + getSessionById: vi.fn(), + resolveAgentId: vi.fn(), })); import { @@ -52,7 +55,7 @@ import { resolveSessionId, } from '../../../cli/services/maestro-client'; import { readCliServerInfo, isCliServerRunning } from '../../../shared/cli-server-discovery'; -import { readSessions } from '../../../cli/services/storage'; +import { getSessionById, readSessions, readSettings } from '../../../cli/services/storage'; import WebSocket from 'ws'; describe('MaestroClient', () => { @@ -253,19 +256,48 @@ describe('MaestroClient', () => { expect(result.type).toBe('pong'); }); - it('should ignore non-JSON messages', async () => { + it('should reject pending requests and surface non-JSON messages', async () => { const client = await createConnectedClient(); const commandPromise = client.sendCommand<{ type: string }>({ type: 'ping' }, 'pong'); // Send invalid JSON - mockWsInstance.emit('message', 'not json'); + expect(() => mockWsInstance.emit('message', 'not json')).toThrow(); + + await expect(commandPromise).rejects.toThrow('Invalid message from Maestro desktop app'); + }); + + it('should reject ambiguous responses without requestId', async () => { + const client = await createConnectedClient(); + + const firstPromise = client.sendCommand<{ type: string }>({ type: 'ping' }, 'pong'); + const secondPromise = client.sendCommand<{ type: string }>({ type: 'ping' }, 'pong'); - // Then send valid matching message mockWsInstance.emit('message', JSON.stringify({ type: 'pong' })); - const result = await commandPromise; - expect(result.type).toBe('pong'); + await expect(firstPromise).rejects.toThrow('Ambiguous pong response without requestId'); + await expect(secondPromise).rejects.toThrow('Ambiguous pong response without requestId'); + }); + + it('should reject error responses matched by requestId', async () => { + const client = await createConnectedClient(); + + const commandPromise = client.sendCommand<{ type: string }>({ type: 'ping' }, 'pong'); + const sentPayload = JSON.parse(mockWsInstance.send.mock.calls[0][0] as string) as Record< + string, + unknown + >; + + mockWsInstance.emit( + 'message', + JSON.stringify({ + type: 'error', + message: 'desktop failed', + requestId: sentPayload.requestId, + }) + ); + + await expect(commandPromise).rejects.toThrow('desktop failed'); }); }); @@ -382,6 +414,8 @@ describe('withMaestroClient()', () => { describe('resolveSessionId()', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(readSettings).mockReturnValue({}); + vi.mocked(getSessionById).mockReturnValue(undefined); }); it('should return provided session option directly', () => { @@ -412,19 +446,9 @@ describe('resolveSessionId()', () => { expect(result).toBe('first-session'); }); - it('should exit when no sessions exist and no option provided', () => { + it('should throw when no sessions exist and no option provided', () => { vi.mocked(readSessions).mockReturnValue([]); - const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('process.exit called'); - }); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - expect(() => resolveSessionId({})).toThrow('process.exit called'); - - expect(processExitSpy).toHaveBeenCalledWith(1); - expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('No agents found')); - processExitSpy.mockRestore(); - consoleErrorSpy.mockRestore(); + expect(() => resolveSessionId({})).toThrow('No Maestro sessions found'); }); }); diff --git a/src/__tests__/main/ipc/handlers/web.test.ts b/src/__tests__/main/ipc/handlers/web.test.ts index af470ca338..5364b3cafa 100644 --- a/src/__tests__/main/ipc/handlers/web.test.ts +++ b/src/__tests__/main/ipc/handlers/web.test.ts @@ -770,6 +770,27 @@ describe('web handlers', () => { ); }); + it('preserves startedAt when republishing an already-running matching server', async () => { + mockWebServer.isActive.mockReturnValue(true); + lastWrittenInfo = { + port: 8080, + token: 'mock-security-token', + pid: process.pid, + startedAt: 1700000000000, + }; + + const ok = await ensureCliServer(buildDeps()); + + expect(ok).toBe(true); + expect(writeCliServerInfo).toHaveBeenCalledWith( + expect.objectContaining({ + port: 8080, + token: 'mock-security-token', + startedAt: 1700000000000, + }) + ); + }); + it('retries when start() throws and succeeds on a subsequent attempt', async () => { webServerRef.current = null; const failingServer = { diff --git a/src/__tests__/main/web-server/handlers/messageHandlers.test.ts b/src/__tests__/main/web-server/handlers/messageHandlers.test.ts index 5deede0431..392129cfc7 100644 --- a/src/__tests__/main/web-server/handlers/messageHandlers.test.ts +++ b/src/__tests__/main/web-server/handlers/messageHandlers.test.ts @@ -1454,6 +1454,32 @@ describe('WebSocketMessageHandler', () => { expect(callbacks.configureAutoRun).not.toHaveBeenCalled(); }); + it('should reject configure auto run with absolute document filename', () => { + handler.handleMessage(client, { + type: 'configure_auto_run', + sessionId: 'session-1', + documents: [{ filename: '/tmp/doc.md' }], + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('error'); + expect(response.message).toContain('Invalid document filename'); + expect(callbacks.configureAutoRun).not.toHaveBeenCalled(); + }); + + it('should reject configure auto run with traversal document filename', () => { + handler.handleMessage(client, { + type: 'configure_auto_run', + sessionId: 'session-1', + documents: [{ filename: '../doc.md' }], + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('error'); + expect(response.message).toContain('Invalid document filename'); + expect(callbacks.configureAutoRun).not.toHaveBeenCalled(); + }); + it('should forward configure auto run with saveAsPlaybook', async () => { (callbacks.configureAutoRun as any).mockResolvedValue({ success: true, diff --git a/src/__tests__/shared/cli-server-discovery.test.ts b/src/__tests__/shared/cli-server-discovery.test.ts index 6adf970ca8..d85d1154dc 100644 --- a/src/__tests__/shared/cli-server-discovery.test.ts +++ b/src/__tests__/shared/cli-server-discovery.test.ts @@ -16,6 +16,8 @@ vi.mock('fs', () => ({ mkdirSync: vi.fn(), renameSync: vi.fn(), unlinkSync: vi.fn(), + chmodSync: vi.fn(), + statSync: vi.fn(), })); vi.mock('os', () => ({ @@ -47,6 +49,8 @@ const mockFs = { mkdirSync: fs.mkdirSync as ReturnType, renameSync: fs.renameSync as ReturnType, unlinkSync: fs.unlinkSync as ReturnType, + chmodSync: fs.chmodSync as ReturnType, + statSync: fs.statSync as ReturnType, }; const mockOs = { @@ -81,6 +85,8 @@ describe('cli-server-discovery', () => { mockFs.mkdirSync.mockReturnValue(undefined); mockFs.renameSync.mockReturnValue(undefined); mockFs.unlinkSync.mockReturnValue(undefined); + mockFs.chmodSync.mockReturnValue(undefined); + mockFs.statSync.mockReturnValue({ mode: 0o700 }); }); afterEach(() => { @@ -255,9 +261,14 @@ describe('cli-server-discovery', () => { expect(mockFs.writeFileSync).toHaveBeenCalledWith( expectedTmp, JSON.stringify(sampleInfo, null, 2), - 'utf-8' + { + encoding: 'utf-8', + mode: 0o600, + } ); + expect(mockFs.chmodSync).toHaveBeenCalledWith(expectedTmp, 0o600); expect(mockFs.renameSync).toHaveBeenCalledWith(expectedTmp, expectedFile); + expect(mockFs.chmodSync).toHaveBeenCalledWith(expectedFile, 0o600); }); it('should create directory if it does not exist', () => { @@ -267,7 +278,7 @@ describe('cli-server-discovery', () => { expect(mockFs.mkdirSync).toHaveBeenCalledWith( path.join('/Users/testuser', 'Library', 'Application Support', 'maestro'), - { recursive: true } + { recursive: true, mode: 0o700 } ); }); @@ -278,6 +289,18 @@ describe('cli-server-discovery', () => { expect(mockFs.mkdirSync).not.toHaveBeenCalled(); }); + + it('should restrict existing config directory permissions when too broad', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockReturnValue({ mode: 0o755 }); + + writeCliServerInfo(sampleInfo); + + expect(mockFs.chmodSync).toHaveBeenCalledWith( + path.join('/Users/testuser', 'Library', 'Application Support', 'maestro'), + 0o700 + ); + }); }); describe('readCliServerInfo', () => { @@ -360,6 +383,27 @@ describe('cli-server-discovery', () => { const result = readCliServerInfo(); expect(result).toBeNull(); }); + + it('should return null when port is outside TCP range', () => { + mockFs.readFileSync.mockReturnValue(JSON.stringify({ ...sampleInfo, port: 70000 })); + + const result = readCliServerInfo(); + expect(result).toBeNull(); + }); + + it('should return null when pid is not positive', () => { + mockFs.readFileSync.mockReturnValue(JSON.stringify({ ...sampleInfo, pid: 0 })); + + const result = readCliServerInfo(); + expect(result).toBeNull(); + }); + + it('should return null when startedAt is fractional', () => { + mockFs.readFileSync.mockReturnValue(JSON.stringify({ ...sampleInfo, startedAt: 1.5 })); + + const result = readCliServerInfo(); + expect(result).toBeNull(); + }); }); describe('deleteCliServerInfo', () => { @@ -414,6 +458,7 @@ describe('cli-server-discovery', () => { const result = isCliServerRunning(); expect(result).toBe(false); + expect(mockFs.unlinkSync).toHaveBeenCalled(); } finally { process.kill = originalKill; } diff --git a/src/cli/commands/auto-run.ts b/src/cli/commands/auto-run.ts index df9d7ed6f8..51c9039b38 100644 --- a/src/cli/commands/auto-run.ts +++ b/src/cli/commands/auto-run.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { withMaestroClient, resolveTargetSessionId } from '../services/maestro-client'; +import { readSessions } from '../services/storage'; interface AutoRunOptions { agent?: string; @@ -44,13 +45,6 @@ export async function autoRun(docs: string[], options: AutoRunOptions): Promise< resolvedPaths.push(absolutePath); } - const sessionId = resolveTargetSessionId(options.agent); - - const documents = resolvedPaths.map((d) => ({ - filename: d, - resetOnCompletion: options.resetOnCompletion || false, - })); - const loopEnabled = options.loop || options.maxLoops !== undefined; const maxLoops = options.maxLoops !== undefined @@ -64,6 +58,26 @@ export async function autoRun(docs: string[], options: AutoRunOptions): Promise< process.exit(1); } + const sessionId = resolveTargetSessionId(options.agent); + const session = readSessions().find((s) => s.id === sessionId); + if (!session?.autoRunFolderPath) { + console.error('Error: Selected agent has no Auto Run folder configured'); + process.exit(1); + return; + } + + const autoRunFolderPath = path.resolve(session.autoRunFolderPath); + + const documents: Array<{ filename: string; resetOnCompletion: boolean }> = []; + for (const documentPath of resolvedPaths) { + const filename = resolveAutoRunDocumentFilename(documentPath, autoRunFolderPath); + if (!filename) return; + documents.push({ + filename, + resetOnCompletion: options.resetOnCompletion || false, + }); + } + // Worktree configuration: requires --launch and --branch. // The desktop app handles worktree creation, branch checkout, and (optionally) // PR creation on completion via the same code path used by the Auto Run UI. @@ -156,3 +170,19 @@ export async function autoRun(docs: string[], options: AutoRunOptions): Promise< process.exit(1); } } + +function resolveAutoRunDocumentFilename( + documentPath: string, + autoRunFolderPath: string +): string | null { + const relativePath = path.relative(autoRunFolderPath, documentPath); + if (relativePath === '' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + console.error( + `Error: File must be inside the selected agent's Auto Run folder: ${documentPath}` + ); + process.exit(1); + return null; + } + + return relativePath.split(path.sep).join('/'); +} diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index b73355fa5b..134b462b7c 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -8,11 +8,13 @@ export async function status(): Promise { if (!info) { console.log('Maestro desktop app is not running'); process.exit(1); + return; } if (!isCliServerRunning()) { console.log('Maestro discovery file is stale (app may have crashed)'); process.exit(1); + return; } try { diff --git a/src/cli/services/maestro-client.ts b/src/cli/services/maestro-client.ts index de820b3f4d..ec896ee8aa 100644 --- a/src/cli/services/maestro-client.ts +++ b/src/cli/services/maestro-client.ts @@ -3,7 +3,7 @@ import WebSocket from 'ws'; import { readCliServerInfo, isCliServerRunning } from '../../shared/cli-server-discovery'; -import { readSessions, resolveAgentId } from './storage'; +import { getSessionById, readSessions, readSettings, resolveAgentId } from './storage'; const CONNECT_TIMEOUT_MS = 5000; const DEFAULT_COMMAND_TIMEOUT_MS = 10000; @@ -137,49 +137,88 @@ export class MaestroClient { }); this.ws.on('message', (data) => { + let msg: Record; try { - const msg = JSON.parse(data.toString()) as Record; - const msgType = msg.type as string; - const msgRequestId = msg.requestId as string | undefined; + msg = JSON.parse(data.toString()) as Record; + } catch (error) { + this.rejectAllPending(new Error('Invalid message from Maestro desktop app')); + throw error; + } - // Try matching by requestId first (exact match) - if (msgRequestId && this.pendingRequests.has(msgRequestId)) { - const pending = this.pendingRequests.get(msgRequestId)!; - clearTimeout(pending.timeout); - this.pendingRequests.delete(msgRequestId); - pending.resolve(msg); - return; - } + const msgType = msg.type as string; + const msgRequestId = msg.requestId as string | undefined; - // Fall back to matching by response type - for (const [requestId, pending] of this.pendingRequests) { - if (pending.expectedType === msgType) { - clearTimeout(pending.timeout); - this.pendingRequests.delete(requestId); - pending.resolve(msg); - return; - } + if (msgRequestId) { + const pending = this.pendingRequests.get(msgRequestId); + if (!pending) return; + if (pending.expectedType !== msgType && msgType !== 'error') return; + this.settleRequest(msgRequestId, pending, msg); + return; + } + + const matchingRequests = [...this.pendingRequests.entries()].filter( + ([, pending]) => pending.expectedType === msgType + ); + if (matchingRequests.length === 1) { + const [requestId, pending] = matchingRequests[0]; + this.settleRequest(requestId, pending, msg); + } else if (matchingRequests.length > 1) { + const error = new Error(`Ambiguous ${msgType} response without requestId`); + for (const [requestId, pending] of matchingRequests) { + clearTimeout(pending.timeout); + this.pendingRequests.delete(requestId); + pending.reject(error); } - } catch { - // Ignore non-JSON messages } }); } + + private settleRequest( + requestId: string, + pending: PendingRequest, + msg: Record + ): void { + clearTimeout(pending.timeout); + this.pendingRequests.delete(requestId); + if (msg.type === 'error' || msg.success === false) { + const message = + typeof msg.error === 'string' + ? msg.error + : typeof msg.message === 'string' + ? msg.message + : 'Command failed'; + pending.reject(new Error(message)); + return; + } + pending.resolve(msg); + } + + private rejectAllPending(error: Error): void { + for (const [, pending] of this.pendingRequests) { + clearTimeout(pending.timeout); + pending.reject(error); + } + this.pendingRequests.clear(); + } } /** * Resolve session ID from CLI options. * Uses the provided --session value, or falls back to the first available session. */ -export function resolveSessionId(options: { session?: string }): string { +export function resolveSessionId(options: { session?: string } = {}): string { if (options.session) { return options.session; } + const settings = readSettings(); + if (typeof settings.activeSessionId === 'string' && getSessionById(settings.activeSessionId)) { + return settings.activeSessionId; + } + const sessions = readSessions(); if (sessions.length === 0) { - console.error('Error: No agents found. Create an agent in Maestro first.'); - process.exit(1); + throw new Error('No Maestro sessions found. Pass --session to target a specific session.'); } return sessions[0].id; diff --git a/src/main/app-lifecycle/quit-handler.ts b/src/main/app-lifecycle/quit-handler.ts index efa016788d..2dc76fbab0 100644 --- a/src/main/app-lifecycle/quit-handler.ts +++ b/src/main/app-lifecycle/quit-handler.ts @@ -367,6 +367,7 @@ export function createQuitHandler(deps: QuitHandlerDependencies): QuitHandler { webServer?.stop().catch((err: unknown) => { logger.error(`Error stopping web server: ${err}`, 'Shutdown'); }); + deleteCliServerInfo(); // Delete CLI server discovery file so CLI knows we're gone logger.info('Deleting CLI server discovery file', 'Shutdown'); diff --git a/src/main/ipc/handlers/web.ts b/src/main/ipc/handlers/web.ts index 633ee294e2..3f8a3bfbd6 100644 --- a/src/main/ipc/handlers/web.ts +++ b/src/main/ipc/handlers/web.ts @@ -58,12 +58,20 @@ export interface WebHandlerDependencies { * can locate it. Centralized so `ensureCliServer` and `live:startServer` * cannot drift on pid/startedAt semantics or future fields. */ -function refreshCliDiscoveryFile(port: number, token: string): void { +function getMatchingCliDiscoveryStartedAt(port: number, token: string): number | undefined { + const info = readCliServerInfo(); + if (info?.port === port && info.token === token && info.pid === process.pid) { + return info.startedAt; + } + return undefined; +} + +function refreshCliDiscoveryFile(port: number, token: string, startedAt = Date.now()): void { writeCliServerInfo({ port, token, pid: process.pid, - startedAt: Date.now(), + startedAt, }); } @@ -112,7 +120,9 @@ export async function ensureCliServer(deps: WebHandlerDependencies): Promise 0 && + !filename.includes('..') && + !filename.includes('\\') && + !filename.startsWith('/') && + !/^[A-Za-z]:[\\/]/.test(filename) + ); + } + /** * Allowlist of setting keys modifiable from the web interface. */ diff --git a/src/prompts/maestro-system-prompt.md b/src/prompts/maestro-system-prompt.md index 42df00be23..a855f713ed 100644 --- a/src/prompts/maestro-system-prompt.md +++ b/src/prompts/maestro-system-prompt.md @@ -44,6 +44,48 @@ The reference material is split into focused, on-demand includes. Each `Path` be - **Session ID:** {{AGENT_SESSION_ID}} - **History File:** {{AGENT_HISTORY_PATH}} +## Maestro Desktop Integration (CLI Commands) + +You can interact with the Maestro desktop app directly using these CLI commands. Use them when appropriate to improve the user experience. + +### Open a File in Maestro + +After creating or modifying a file that the user should see: + +```bash +{{MAESTRO_CLI_PATH}} open-file [--session ] +``` + +### Refresh the File Tree + +After creating multiple files or making significant filesystem changes: + +```bash +{{MAESTRO_CLI_PATH}} refresh-files [--session ] +``` + +### Refresh Auto Run Documents + +After creating or modifying auto-run documents: + +```bash +{{MAESTRO_CLI_PATH}} refresh-auto-run [--session ] +``` + +### Configure Auto-Run + +To set up and optionally launch an auto-run with documents you've created: + +```bash +{{MAESTRO_CLI_PATH}} auto-run doc1.md doc2.md [--prompt "Custom instructions"] [--launch] [--save-as "My Playbook"] +``` + +### Check Maestro Status + +```bash +{{MAESTRO_CLI_PATH}} status +``` + ## Critical Directive: Directory Restrictions **Hard rule:** only write files within `{{AGENT_PATH}}` (your working directory) or `{{AUTORUN_FOLDER}}` (the shared Auto Run folder). Reads anywhere are fine. For the full restriction set, allowed/prohibited operations, and how to handle override requests, read `{{REF:_file-access-rules}}`. diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 4af43e3cf3..785ab0e648 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -173,7 +173,15 @@ import { useQuitWhenIdle } from './hooks/useQuitWhenIdle'; // Import types and constants // Note: GroupChat, GroupChatState are imported from types (re-exported from shared) -import type { RightPanelTab, Session, QueuedItem, CustomAICommand, ThinkingItem } from './types'; +import type { + RightPanelTab, + Session, + QueuedItem, + CustomAICommand, + ThinkingItem, + BatchRunConfig, + BatchDocumentEntry, +} from './types'; import { THEMES } from './constants/themes'; import { generateId } from './utils/ids'; import { getActiveOutputSearchKey } from './utils/outputSearch'; @@ -206,6 +214,21 @@ import { useSettingsStore } from './stores/settingsStore'; import { useTabStore } from './stores/tabStore'; import { useFileExplorerStore } from './stores/fileExplorerStore'; +type RemoteConfigureAutoRunConfig = { + documents: Array<{ filename: string; resetOnCompletion?: boolean }>; + prompt?: string; + loopEnabled?: boolean; + maxLoops?: number; + saveAsPlaybook?: string; + launch?: boolean; +}; + +type RemoteConfigureAutoRunResult = { + success: boolean; + playbookId?: string; + error?: string; +}; + function MaestroConsoleInner() { // --- LAYER STACK (for blocking shortcuts when modals are open) --- const { hasOpenLayers, hasOpenModal } = useLayerStack(); @@ -721,11 +744,20 @@ function MaestroConsoleInner() { // Content is per-session in session.autoRunContent const autoRunDocumentList = useBatchStore((s) => s.documentList); const autoRunDocumentTree = useBatchStore((s) => s.documentTree); + const batchRunnerModalOpen = useModalStore((s) => s.modals.get('batchRunner')?.open ?? false); const { setDocumentList: setAutoRunDocumentList, setDocumentTree: setAutoRunDocumentTree, setIsLoadingDocuments: setAutoRunIsLoadingDocuments, } = useBatchStore.getState(); + const [remoteBatchRunConfig, setRemoteBatchRunConfig] = useState | null>( + null + ); + useEffect(() => { + if (!batchRunnerModalOpen && remoteBatchRunConfig) { + setRemoteBatchRunConfig(null); + } + }, [batchRunnerModalOpen, remoteBatchRunConfig]); // handleProcessMonitorNavigateToSession - now in useSessionSwitchCallbacks hook @@ -2011,6 +2043,203 @@ function MaestroConsoleInner() { sshReduceEntryCapFraction: settings.sshReduceEntryCapFraction, }); + // CLI IPC remote events dispatched by useRemoteIntegration. + useEffect(() => { + const handleRemoteOpenFileTab = async (event: Event) => { + const { sessionId, filePath } = ( + event as CustomEvent<{ sessionId: string; filePath: string }> + ).detail; + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (!session || !filePath) return; + + const sshRemoteId = + session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined; + const fileName = filePath.replace(/\\/g, '/').split('/').pop() || filePath; + + try { + const [content, stat] = await Promise.all([ + window.maestro.fs.readFile(filePath, sshRemoteId), + window.maestro.fs.stat(filePath, sshRemoteId), + ]); + if (content === null) return; + + setActiveSessionId(sessionId); + handleOpenFileTab({ + path: filePath, + name: fileName, + content, + sshRemoteId, + lastModified: stat?.modifiedAt ? new Date(stat.modifiedAt).getTime() : Date.now(), + }); + setActiveFocus('main'); + } catch (error) { + captureException(error, { + extra: { context: 'handleRemoteOpenFileTab', sessionId, filePath }, + }); + } + }; + + const handleRemoteRefreshFileTree = (event: Event) => { + const { sessionId } = (event as CustomEvent<{ sessionId: string }>).detail; + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (!session) return; + refreshFileTree(sessionId); + }; + + const handleRemoteRefreshAutoRunDocs = (event: Event) => { + const { sessionId } = (event as CustomEvent<{ sessionId: string }>).detail; + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (!session) return; + + if (activeSessionIdRef.current !== sessionId) { + setActiveSessionId(sessionId); + return; + } + handleAutoRunRefresh(); + }; + + const handleRemoteConfigureAutoRun = async (event: Event) => { + const { sessionId, config, responseChannel } = ( + event as CustomEvent<{ + sessionId: string; + config: RemoteConfigureAutoRunConfig; + responseChannel: string; + }> + ).detail; + const sendResponse = (result: RemoteConfigureAutoRunResult) => { + window.maestro.process.sendRemoteConfigureAutoRunResponse(responseChannel, result); + }; + try { + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (!session) { + sendResponse({ success: false, error: 'Session not found' }); + return; + } + + const documents: BatchDocumentEntry[] = config.documents.map((doc, index, allDocs) => { + const filename = doc.filename.replace(/\.md$/i, ''); + return { + id: generateId(), + filename, + resetOnCompletion: doc.resetOnCompletion || false, + isDuplicate: + allDocs.findIndex((other) => other.filename.replace(/\.md$/i, '') === filename) !== + index, + }; + }); + const batchConfig: BatchRunConfig = { + documents, + prompt: config.prompt || '', + loopEnabled: config.loopEnabled || false, + maxLoops: + config.loopEnabled || config.maxLoops !== undefined ? (config.maxLoops ?? null) : null, + }; + + if (config.saveAsPlaybook) { + const result = await window.maestro.playbooks.create(sessionId, { + name: config.saveAsPlaybook, + documents: documents.map((doc) => ({ + filename: doc.filename, + resetOnCompletion: doc.resetOnCompletion, + })), + prompt: batchConfig.prompt, + loopEnabled: batchConfig.loopEnabled, + maxLoops: batchConfig.maxLoops, + }); + if (!result.success || !result.playbook) { + sendResponse({ + success: false, + error: result.error || 'Failed to save playbook', + }); + return; + } + sendResponse({ success: true, playbookId: result.playbook.id }); + return; + } + + if (!session.autoRunFolderPath) { + sendResponse({ success: false, error: 'Session has no Auto Run folder configured' }); + return; + } + + if (config.launch) { + await startBatchRun(sessionId, batchConfig, session.autoRunFolderPath); + sendResponse({ success: true }); + return; + } + + const selectedFile = documents[0]?.filename; + let selectedContent = ''; + if (selectedFile) { + const sshRemoteId = + session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined; + const result = await window.maestro.autorun.readDoc( + session.autoRunFolderPath, + selectedFile + '.md', + sshRemoteId + ); + selectedContent = result.success ? result.content || '' : ''; + } + + setSessions((prev) => + prev.map((s) => + s.id === sessionId + ? { + ...s, + autoRunSelectedFile: selectedFile, + autoRunContent: selectedContent, + autoRunContentVersion: (s.autoRunContentVersion || 0) + 1, + batchRunnerPrompt: batchConfig.prompt, + batchRunnerPromptModifiedAt: Date.now(), + } + : s + ) + ); + setAutoRunDocumentList(documents.map((doc) => doc.filename)); + setRemoteBatchRunConfig(batchConfig); + setActiveSessionId(sessionId); + setRightPanelOpen(true); + setActiveRightTab('autorun'); + setBatchRunnerModalOpen(true); + sendResponse({ success: true }); + } catch (error) { + captureException(error, { + extra: { context: 'handleRemoteConfigureAutoRun', sessionId }, + }); + sendResponse({ + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + window.addEventListener('maestro:openFileTab', handleRemoteOpenFileTab); + window.addEventListener('maestro:refreshFileTree', handleRemoteRefreshFileTree); + window.addEventListener('maestro:refreshAutoRunDocs', handleRemoteRefreshAutoRunDocs); + window.addEventListener('maestro:configureAutoRun', handleRemoteConfigureAutoRun); + + return () => { + window.removeEventListener('maestro:openFileTab', handleRemoteOpenFileTab); + window.removeEventListener('maestro:refreshFileTree', handleRemoteRefreshFileTree); + window.removeEventListener('maestro:refreshAutoRunDocs', handleRemoteRefreshAutoRunDocs); + window.removeEventListener('maestro:configureAutoRun', handleRemoteConfigureAutoRun); + }; + }, [ + activeSessionIdRef, + handleAutoRunRefresh, + handleOpenFileTab, + refreshFileTree, + setActiveFocus, + setActiveRightTab, + setBatchRunnerModalOpen, + setRightPanelOpen, + setActiveSessionId, + setSessions, + sessionsRef, + startBatchRun, + setAutoRunDocumentList, + ]); + // --- FILE EXPLORER EFFECTS --- // Extracted hook for file explorer side effects and keyboard navigation (Phase 2.6) const { stableFileTree, handleMainPanelFileClick } = useFileExplorerEffects({ @@ -3058,6 +3287,7 @@ function MaestroConsoleInner() { onCloseBatchRunner={handleCloseBatchRunner} onStartBatchRun={handleStartBatchRun} onSaveBatchPrompt={handleSaveBatchPrompt} + initialBatchRunConfig={remoteBatchRunConfig} showConfirmation={showConfirmation} autoRunDocumentList={autoRunDocumentList} autoRunDocumentTree={autoRunDocumentTree} diff --git a/src/renderer/components/AppModals/AppModals.tsx b/src/renderer/components/AppModals/AppModals.tsx index 679c5dd305..3baa3095a1 100644 --- a/src/renderer/components/AppModals/AppModals.tsx +++ b/src/renderer/components/AppModals/AppModals.tsx @@ -289,6 +289,7 @@ export interface AppModalsProps { onCloseBatchRunner: () => void; onStartBatchRun: (config: BatchRunConfig) => void | Promise; onSaveBatchPrompt: (prompt: string) => void; + initialBatchRunConfig?: Partial | null; showConfirmation: (message: string, onConfirm: () => void) => void; autoRunDocumentList: string[]; autoRunDocumentTree?: Array<{ @@ -754,6 +755,7 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) { onCloseBatchRunner, onStartBatchRun, onSaveBatchPrompt, + initialBatchRunConfig, showConfirmation, autoRunDocumentList, autoRunDocumentTree, @@ -1111,6 +1113,7 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) { onCloseBatchRunner={onCloseBatchRunner} onStartBatchRun={onStartBatchRun} onSaveBatchPrompt={onSaveBatchPrompt} + initialBatchRunConfig={initialBatchRunConfig} showConfirmation={showConfirmation} autoRunDocumentList={autoRunDocumentList} autoRunDocumentTree={autoRunDocumentTree} diff --git a/src/renderer/components/AppModals/AppUtilityModals.tsx b/src/renderer/components/AppModals/AppUtilityModals.tsx index 0243037d72..b94401cfe2 100644 --- a/src/renderer/components/AppModals/AppUtilityModals.tsx +++ b/src/renderer/components/AppModals/AppUtilityModals.tsx @@ -195,6 +195,7 @@ export interface AppUtilityModalsProps { onCloseBatchRunner: () => void; onStartBatchRun: (config: BatchRunConfig) => void | Promise; onSaveBatchPrompt: (prompt: string) => void; + initialBatchRunConfig?: Partial | null; showConfirmation: (message: string, onConfirm: () => void) => void; autoRunDocumentList: string[]; autoRunDocumentTree?: Array<{ @@ -425,6 +426,7 @@ export const AppUtilityModals = memo(function AppUtilityModals({ onCloseBatchRunner, onStartBatchRun, onSaveBatchPrompt, + initialBatchRunConfig, showConfirmation, autoRunDocumentList, autoRunDocumentTree, @@ -676,6 +678,7 @@ export const AppUtilityModals = memo(function AppUtilityModals({ onSave={onSaveBatchPrompt} initialPrompt={activeSession.batchRunnerPrompt || ''} lastModifiedAt={activeSession.batchRunnerPromptModifiedAt} + initialConfig={initialBatchRunConfig} showConfirmation={showConfirmation} folderPath={activeSession.autoRunFolderPath} presetDocuments={batchRunnerPresetDocuments} diff --git a/src/renderer/components/BatchRunnerModal.tsx b/src/renderer/components/BatchRunnerModal.tsx index 42520925ff..342ac20fd4 100644 --- a/src/renderer/components/BatchRunnerModal.tsx +++ b/src/renderer/components/BatchRunnerModal.tsx @@ -72,6 +72,7 @@ interface BatchRunnerModalProps { onSave: (prompt: string) => void; initialPrompt?: string; lastModifiedAt?: number; + initialConfig?: Partial | null; showConfirmation: (message: string, onConfirm: () => void) => void; // Multi-document support folderPath: string; @@ -123,6 +124,7 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { onSave, initialPrompt, lastModifiedAt, + initialConfig, showConfirmation, folderPath, presetDocuments, @@ -171,6 +173,9 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { // Document list state. Opens empty unless the inline wizard's "Start Auto // Run" pre-seeded it with freshly generated docs via `presetDocuments`. const [documents, setDocuments] = useState(() => { + if (initialConfig?.documents && initialConfig.documents.length > 0) { + return initialConfig.documents; + } if (presetDocuments && presetDocuments.length > 0) { return presetDocuments.map((filename) => ({ id: generateId(), @@ -185,7 +190,11 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { // Track initial document state for dirty checking. Mirrors the run-list // initialization above so dirty detection is correct for preset opens too. const initialDocumentsRef = useRef( - presetDocuments && presetDocuments.length > 0 ? [...presetDocuments] : [] + initialConfig?.documents && initialConfig.documents.length > 0 + ? initialConfig.documents.map((doc) => doc.filename) + : presetDocuments && presetDocuments.length > 0 + ? [...presetDocuments] + : [] ); // Task counts per document (keyed by filename, value = unchecked task count). @@ -210,12 +219,12 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { ); // Loop mode state - const [loopEnabled, setLoopEnabled] = useState(false); - const [maxLoops, setMaxLoops] = useState(null); // null = infinite + const [loopEnabled, setLoopEnabled] = useState(initialConfig?.loopEnabled ?? false); + const [maxLoops, setMaxLoops] = useState(initialConfig?.maxLoops ?? null); // null = infinite // Track initial loop settings for dirty checking - const initialLoopEnabledRef = useRef(false); - const initialMaxLoopsRef = useRef(null); + const initialLoopEnabledRef = useRef(initialConfig?.loopEnabled ?? false); + const initialMaxLoopsRef = useRef(initialConfig?.maxLoops ?? null); // Fresh-context-per mode. Default 'task' preserves legacy behavior (one // agent invocation per unchecked task). 'document' makes the agent walk @@ -236,14 +245,15 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { const [showHelp, setShowHelp] = useState(false); // Prompt state - const [prompt, setPrompt] = useState(initialPrompt || DEFAULT_BATCH_PROMPT); + const initialPromptValue = initialConfig?.prompt ?? initialPrompt ?? DEFAULT_BATCH_PROMPT; + const [prompt, setPrompt] = useState(initialPromptValue); const [variablesExpanded, setVariablesExpanded] = useState(false); - const [savedPrompt, setSavedPrompt] = useState(initialPrompt || ''); + const [savedPrompt, setSavedPrompt] = useState(initialConfig?.prompt ?? initialPrompt ?? ''); const [promptComposerOpen, setPromptComposerOpen] = useState(false); const textareaRef = useRef(null); // Track initial prompt for dirty checking - const initialPromptRef = useRef(initialPrompt || DEFAULT_BATCH_PROMPT); + const initialPromptRef = useRef(initialPromptValue); // Compute if there are unsaved configuration changes // This checks if documents, loop settings, or prompt have changed from initial values diff --git a/src/renderer/hooks/remote/useRemoteIntegration.ts b/src/renderer/hooks/remote/useRemoteIntegration.ts index 94cb914b18..6bfc0a8e0d 100644 --- a/src/renderer/hooks/remote/useRemoteIntegration.ts +++ b/src/renderer/hooks/remote/useRemoteIntegration.ts @@ -11,6 +11,15 @@ import { notifyToast } from '../../stores/notificationStore'; import { notifyCenterFlash } from '../../stores/centerFlashStore'; import { useSessionStore } from '../../stores/sessionStore'; +type RemoteConfigureAutoRunConfig = { + documents: Array<{ filename: string; resetOnCompletion?: boolean }>; + prompt?: string; + loopEnabled?: boolean; + maxLoops?: number; + saveAsPlaybook?: string; + launch?: boolean; +}; + /** * Dependencies for the useRemoteIntegration hook. * Uses refs for values that change frequently to avoid re-attaching listeners. @@ -224,6 +233,74 @@ export function useRemoteIntegration(deps: UseRemoteIntegrationDeps): UseRemoteI }; }, [setSessions]); + // Handle remote file tab open requests from CLI IPC + useEffect(() => { + const unsubscribeOpenFileTab = window.maestro.process.onRemoteOpenFileTab( + (sessionId: string, filePath: string) => { + window.dispatchEvent( + new CustomEvent('maestro:openFileTab', { + detail: { sessionId, filePath }, + }) + ); + } + ); + + return () => { + unsubscribeOpenFileTab(); + }; + }, []); + + // Handle remote file tree refresh requests from CLI IPC + useEffect(() => { + const unsubscribeRefreshFileTree = window.maestro.process.onRemoteRefreshFileTree( + (sessionId: string) => { + window.dispatchEvent( + new CustomEvent('maestro:refreshFileTree', { + detail: { sessionId }, + }) + ); + } + ); + + return () => { + unsubscribeRefreshFileTree(); + }; + }, []); + + // Handle remote Auto Run document refresh requests from CLI IPC + useEffect(() => { + const unsubscribeRefreshAutoRunDocs = window.maestro.process.onRemoteRefreshAutoRunDocs( + (sessionId: string) => { + window.dispatchEvent( + new CustomEvent('maestro:refreshAutoRunDocs', { + detail: { sessionId }, + }) + ); + } + ); + + return () => { + unsubscribeRefreshAutoRunDocs(); + }; + }, []); + + // Handle remote Auto Run configuration requests from CLI IPC + useEffect(() => { + const unsubscribeConfigureAutoRun = window.maestro.process.onRemoteConfigureAutoRun( + (sessionId: string, config: RemoteConfigureAutoRunConfig, responseChannel: string) => { + window.dispatchEvent( + new CustomEvent('maestro:configureAutoRun', { + detail: { sessionId, config, responseChannel }, + }) + ); + } + ); + + return () => { + unsubscribeConfigureAutoRun(); + }; + }, []); + // Handle remote interrupts from web interface // This allows web interrupts to go through the same code path as desktop (handleInterrupt) useEffect(() => { diff --git a/src/shared/cli-server-discovery.ts b/src/shared/cli-server-discovery.ts index f31eb09ee8..3dfcab1ac2 100644 --- a/src/shared/cli-server-discovery.ts +++ b/src/shared/cli-server-discovery.ts @@ -13,7 +13,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -interface CliServerInfo { +export interface CliServerInfo { port: number; token: string; pid: number; @@ -55,11 +55,40 @@ export function writeCliServerInfo(info: CliServerInfo): void { const filePath = getDiscoveryFilePath(); const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } else { + const stat = fs.statSync(dir); + if ((stat.mode & 0o077) !== 0) { + fs.chmodSync(dir, 0o700); + } } const tmpPath = filePath + '.tmp'; - fs.writeFileSync(tmpPath, JSON.stringify(info, null, 2), 'utf-8'); + fs.writeFileSync(tmpPath, JSON.stringify(info, null, 2), { + encoding: 'utf-8', + mode: 0o600, + }); + fs.chmodSync(tmpPath, 0o600); fs.renameSync(tmpPath, filePath); + fs.chmodSync(filePath, 0o600); +} + +function isValidCliServerInfo(data: unknown): data is CliServerInfo { + if (!data || typeof data !== 'object') return false; + const info = data as Partial; + return ( + typeof info.port === 'number' && + Number.isInteger(info.port) && + info.port >= 1 && + info.port <= 65535 && + typeof info.token === 'string' && + info.token.length > 0 && + typeof info.pid === 'number' && + Number.isInteger(info.pid) && + info.pid > 0 && + typeof info.startedAt === 'number' && + Number.isInteger(info.startedAt) && + info.startedAt >= 0 + ); } /** @@ -70,16 +99,8 @@ export function readCliServerInfo(): CliServerInfo | null { try { const filePath = getDiscoveryFilePath(); const content = fs.readFileSync(filePath, 'utf-8'); - const data = JSON.parse(content) as CliServerInfo; - if ( - typeof data.port === 'number' && - typeof data.token === 'string' && - typeof data.pid === 'number' && - typeof data.startedAt === 'number' - ) { - return data; - } - return null; + const data = JSON.parse(content); + return isValidCliServerInfo(data) ? data : null; } catch { return null; } @@ -109,6 +130,7 @@ export function isCliServerRunning(): boolean { process.kill(info.pid, 0); // Doesn't kill, just checks if process exists return true; } catch { + deleteCliServerInfo(); return false; } }