From deec765adff8cf3a09dc8157745f7aa8f269765d Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 17:40:48 -0700 Subject: [PATCH 01/24] [Symphony] Start contribution for #511 From d5975c43b5b40d21a3947f43e707fcd15d31510f Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 17:43:43 -0700 Subject: [PATCH 02/24] MAESTRO: Add CLI server discovery module --- src/shared/cli-server-discovery.ts | 121 +++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/shared/cli-server-discovery.ts diff --git a/src/shared/cli-server-discovery.ts b/src/shared/cli-server-discovery.ts new file mode 100644 index 0000000000..0c5d1f1e22 --- /dev/null +++ b/src/shared/cli-server-discovery.ts @@ -0,0 +1,121 @@ +/** + * CLI Server Discovery + * + * Shared module for publishing the Electron app's CLI IPC server location. + * Used by the desktop app to write connection details and by the CLI to read them. + * + * NOTE: This file has its own `getConfigDir()` implementation (lowercase "maestro") + * which matches the electron-store default from package.json `"name": "maestro"`. + * This mirrors src/shared/cli-activity.ts so CLI state files live in the same + * config directory. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +export interface CliServerInfo { + port: number; + token: string; + pid: number; + startedAt: number; +} + +// Get the Maestro config directory path +function getConfigDir(): string { + const platform = os.platform(); + const home = os.homedir(); + + if (platform === 'darwin') { + return path.join(home, 'Library', 'Application Support', 'maestro'); + } else if (platform === 'win32') { + return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'maestro'); + } else { + // Linux and others + return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'maestro'); + } +} + +const CLI_SERVER_FILE = 'cli-server.json'; + +function getCliServerFilePath(): string { + return path.join(getConfigDir(), CLI_SERVER_FILE); +} + +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.isFinite(info.port) && + typeof info.token === 'string' && + info.token.length > 0 && + typeof info.pid === 'number' && + Number.isFinite(info.pid) && + typeof info.startedAt === 'number' && + Number.isFinite(info.startedAt) + ); +} + +/** + * Write CLI server discovery info atomically. + */ +export function writeCliServerInfo(info: CliServerInfo): void { + try { + const filePath = getCliServerFilePath(); + const tmpPath = `${filePath}.tmp`; + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(tmpPath, JSON.stringify(info, null, 2), 'utf-8'); + fs.renameSync(tmpPath, filePath); + } catch (error) { + console.error('[CLI Server Discovery] Failed to write discovery file:', error); + } +} + +/** + * Read CLI server discovery info. + */ +export function readCliServerInfo(): CliServerInfo | null { + try { + const filePath = getCliServerFilePath(); + const content = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content); + return isValidCliServerInfo(data) ? data : null; + } catch { + return null; + } +} + +/** + * Delete CLI server discovery info. + */ +export function deleteCliServerInfo(): void { + try { + const filePath = getCliServerFilePath(); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch (error) { + console.error('[CLI Server Discovery] Failed to delete discovery file:', error); + } +} + +/** + * Check if the discovered CLI server process is still running. + */ +export function isCliServerRunning(): boolean { + const info = readCliServerInfo(); + if (!info) return false; + + try { + process.kill(info.pid, 0); // Doesn't kill, just checks if process exists + return true; + } catch { + deleteCliServerInfo(); + return false; + } +} From 46603d6be365f85ae0c8da66b93f30fb449cbf5a Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 17:49:13 -0700 Subject: [PATCH 03/24] MAESTRO: Auto-start CLI web server discovery --- src/__tests__/main/ipc/handlers/web.test.ts | 18 +++++++- src/main/index.ts | 22 +++++++-- src/main/ipc/handlers/index.ts | 4 +- src/main/ipc/handlers/web.ts | 49 +++++++++++++-------- 4 files changed, 68 insertions(+), 25 deletions(-) diff --git a/src/__tests__/main/ipc/handlers/web.test.ts b/src/__tests__/main/ipc/handlers/web.test.ts index 781e250be0..8201bfec4a 100644 --- a/src/__tests__/main/ipc/handlers/web.test.ts +++ b/src/__tests__/main/ipc/handlers/web.test.ts @@ -28,7 +28,12 @@ vi.mock('../../../../main/web-server', () => ({ WebServer: vi.fn(), })); +vi.mock('../../../../shared/cli-server-discovery', () => ({ + writeCliServerInfo: vi.fn(), +})); + import { registerWebHandlers } from '../../../../main/ipc/handlers/web'; +import { writeCliServerInfo } from '../../../../shared/cli-server-discovery'; describe('web handlers', () => { let mockWebServer: any; @@ -55,8 +60,13 @@ describe('web handlers', () => { broadcastTabsChange: vi.fn(), broadcastSessionStateChange: vi.fn(), getWebClientCount: vi.fn().mockReturnValue(1), + getPort: vi.fn().mockReturnValue(8080), getSecurityToken: vi.fn().mockReturnValue('mock-security-token'), - start: vi.fn().mockResolvedValue({ port: 8080, url: 'http://localhost:8080' }), + start: vi.fn().mockResolvedValue({ + port: 8080, + token: 'mock-security-token', + url: 'http://localhost:8080', + }), stop: vi.fn().mockResolvedValue(undefined), }; @@ -276,6 +286,12 @@ describe('web handlers', () => { expect(mockCreateWebServer).toHaveBeenCalled(); expect(webServerRef.current).toBe(mockWebServer); // Server was set expect(mockWebServer.start).toHaveBeenCalled(); + expect(writeCliServerInfo).toHaveBeenCalledWith({ + port: 8080, + token: 'mock-security-token', + pid: process.pid, + startedAt: expect.any(Number), + }); expect(result).toEqual({ success: true, url: 'http://localhost:8080' }); }); diff --git a/src/main/index.ts b/src/main/index.ts index aa6a99a2c8..4bf239bbbf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -47,6 +47,7 @@ import { registerFilesystemHandlers, registerAttachmentsHandlers, registerWebHandlers, + ensureCliServer, registerLeaderboardHandlers, registerNotificationsHandlers, registerSymphonyHandlers, @@ -397,6 +398,24 @@ app.whenReady().then(async () => { logger.debug('Setting up IPC handlers', 'Startup'); setupIpcHandlers(); + // Auto-start the shared web server so CLI IPC can discover and connect immediately. + try { + await ensureCliServer({ + getWebServer: () => webServer, + setWebServer: (server) => { + webServer = server; + }, + createWebServer, + settingsStore: store, + }); + } catch (error) { + logger.error(`Failed to initialize CLI web server: ${error}`, 'Startup'); + logger.warn( + 'Continuing without CLI IPC server - CLI live commands will be unavailable', + 'Startup' + ); + } + // Set up process event listeners logger.debug('Setting up process event listeners', 'Startup'); setupProcessListeners(); @@ -433,9 +452,6 @@ app.whenReady().then(async () => { // Start settings file watcher for external changes (e.g., maestro-cli settings set) settingsWatcher.start(); - // Note: Web server is not auto-started - it starts when user enables web interface - // via live:startServer IPC call from the renderer - app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index 1dfd2e8053..97f7b3f03f 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -45,7 +45,7 @@ import { registerDocumentGraphHandlers, DocumentGraphHandlerDependencies } from import { registerSshRemoteHandlers, SshRemoteHandlerDependencies } from './ssh-remote'; import { registerFilesystemHandlers } from './filesystem'; import { registerAttachmentsHandlers, AttachmentsHandlerDependencies } from './attachments'; -import { registerWebHandlers, WebHandlerDependencies } from './web'; +import { registerWebHandlers, ensureCliServer, WebHandlerDependencies } from './web'; import { registerLeaderboardHandlers, LeaderboardHandlerDependencies } from './leaderboard'; import { registerNotificationsHandlers } from './notifications'; import { registerSymphonyHandlers, SymphonyHandlerDependencies } from './symphony'; @@ -85,7 +85,7 @@ export { registerSshRemoteHandlers }; export { registerFilesystemHandlers }; export { registerAttachmentsHandlers }; export type { AttachmentsHandlerDependencies }; -export { registerWebHandlers }; +export { registerWebHandlers, ensureCliServer }; export type { WebHandlerDependencies }; export { registerLeaderboardHandlers }; export type { LeaderboardHandlerDependencies }; diff --git a/src/main/ipc/handlers/web.ts b/src/main/ipc/handlers/web.ts index 0c04753710..482cca772e 100644 --- a/src/main/ipc/handlers/web.ts +++ b/src/main/ipc/handlers/web.ts @@ -27,6 +27,7 @@ import { logger } from '../../utils/logger'; import { WebServer } from '../../web-server'; import type { AITabData } from '../../web-server/services/broadcastService'; import type { SettingsStoreInterface } from '../../stores/types'; +import { writeCliServerInfo } from '../../../shared/cli-server-discovery'; /** * Timeout for waiting for web server to become active (ms) @@ -48,6 +49,33 @@ export interface WebHandlerDependencies { settingsStore: SettingsStoreInterface; } +/** + * Ensure the always-on CLI IPC web server is running and discoverable. + */ +export async function ensureCliServer(deps: WebHandlerDependencies): Promise { + let webServer = deps.getWebServer(); + + if (!webServer) { + logger.info('Creating CLI web server', 'WebServer'); + webServer = deps.createWebServer(); + deps.setWebServer(webServer); + } + + if (!webServer.isActive()) { + logger.info('Starting CLI web server', 'WebServer'); + // WebServer listens on 0.0.0.0 for Live mode LAN access; CLI discovery remains token-gated. + const { port, url } = await webServer.start(); + logger.info(`CLI web server running at ${url} (port ${port})`, 'WebServer'); + } + + writeCliServerInfo({ + port: webServer.getPort(), + token: webServer.getSecurityToken(), + pid: process.pid, + startedAt: Date.now(), + }); +} + /** * Register all web/live-related IPC handlers. */ @@ -219,25 +247,8 @@ export function registerWebHandlers(deps: WebHandlerDependencies): void { // Start web server (creates if needed, starts if not running) ipcMain.handle('live:startServer', async () => { try { - let webServer = getWebServer(); - - // Create web server if it doesn't exist - if (!webServer) { - logger.info('Creating web server', 'WebServer'); - webServer = createWebServer(); - setWebServer(webServer); - } - - // Start if not already running - if (!webServer.isActive()) { - logger.info('Starting web server', 'WebServer'); - const { port, url } = await webServer.start(); - logger.info(`Web server running at ${url} (port ${port})`, 'WebServer'); - return { success: true, url }; - } - - // Already running - return { success: true, url: webServer.getSecureUrl() }; + await ensureCliServer({ getWebServer, setWebServer, createWebServer, settingsStore }); + return { success: true, url: getWebServer()?.getSecureUrl() }; } catch (error: any) { logger.error(`Failed to start web server: ${error.message}`, 'WebServer'); return { success: false, error: error.message }; From d71783c27856481e7add957b39de3cebdebdd878 Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 17:58:01 -0700 Subject: [PATCH 04/24] MAESTRO: Clean up CLI server discovery on shutdown --- src/__tests__/main/app-lifecycle/quit-handler.test.ts | 10 ++++++++++ src/__tests__/main/ipc/handlers/web.test.ts | 5 ++++- src/main/app-lifecycle/quit-handler.ts | 2 ++ src/main/ipc/handlers/web.ts | 4 +++- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/__tests__/main/app-lifecycle/quit-handler.test.ts b/src/__tests__/main/app-lifecycle/quit-handler.test.ts index ffd9e41648..fb83626f1d 100644 --- a/src/__tests__/main/app-lifecycle/quit-handler.test.ts +++ b/src/__tests__/main/app-lifecycle/quit-handler.test.ts @@ -65,6 +65,12 @@ vi.mock('../../../main/power-manager', () => ({ }, })); +vi.mock('../../../shared/cli-server-discovery', () => ({ + deleteCliServerInfo: vi.fn(), +})); + +import { deleteCliServerInfo } from '../../../shared/cli-server-discovery'; + describe('app-lifecycle/quit-handler', () => { let mockMainWindow: { isDestroyed: ReturnType; @@ -308,7 +314,11 @@ describe('app-lifecycle/quit-handler', () => { expect(killOrder).toBeLessThan(clearOrder); expect(mockTunnelManager.stop).toHaveBeenCalled(); expect(mockWebServer.stop).toHaveBeenCalled(); + expect(deleteCliServerInfo).toHaveBeenCalled(); expect(deps.closeStatsDB).toHaveBeenCalled(); + const deleteOrder = vi.mocked(deleteCliServerInfo).mock.invocationCallOrder[0]; + const closeStatsOrder = deps.closeStatsDB.mock.invocationCallOrder[0]; + expect(deleteOrder).toBeLessThan(closeStatsOrder); }); it('should cleanup grooming sessions if any are active', async () => { diff --git a/src/__tests__/main/ipc/handlers/web.test.ts b/src/__tests__/main/ipc/handlers/web.test.ts index 8201bfec4a..7e5a34a3dd 100644 --- a/src/__tests__/main/ipc/handlers/web.test.ts +++ b/src/__tests__/main/ipc/handlers/web.test.ts @@ -29,11 +29,12 @@ vi.mock('../../../../main/web-server', () => ({ })); vi.mock('../../../../shared/cli-server-discovery', () => ({ + deleteCliServerInfo: vi.fn(), writeCliServerInfo: vi.fn(), })); import { registerWebHandlers } from '../../../../main/ipc/handlers/web'; -import { writeCliServerInfo } from '../../../../shared/cli-server-discovery'; +import { deleteCliServerInfo, writeCliServerInfo } from '../../../../shared/cli-server-discovery'; describe('web handlers', () => { let mockWebServer: any; @@ -333,6 +334,7 @@ describe('web handlers', () => { const result = await handler!({}); expect(mockWebServer.stop).toHaveBeenCalled(); + expect(deleteCliServerInfo).toHaveBeenCalled(); expect(webServerRef.current).toBeNull(); expect(result).toEqual({ success: true }); }); @@ -360,6 +362,7 @@ describe('web handlers', () => { expect(mockWebServer.setSessionOffline).toHaveBeenCalledWith('session-1'); expect(mockWebServer.setSessionOffline).toHaveBeenCalledWith('session-2'); expect(mockWebServer.stop).toHaveBeenCalled(); + expect(deleteCliServerInfo).toHaveBeenCalled(); expect(webServerRef.current).toBeNull(); expect(result).toEqual({ success: true, count: 2 }); }); diff --git a/src/main/app-lifecycle/quit-handler.ts b/src/main/app-lifecycle/quit-handler.ts index 7057a715b3..0027bb305a 100644 --- a/src/main/app-lifecycle/quit-handler.ts +++ b/src/main/app-lifecycle/quit-handler.ts @@ -11,6 +11,7 @@ import { tunnelManager as tunnelManagerInstance } from '../tunnel-manager'; import type { HistoryManager } from '../history-manager'; import { isWebContentsAvailable } from '../utils/safe-send'; import { powerManager as powerManagerInstance } from '../power-manager'; +import { deleteCliServerInfo } from '../../shared/cli-server-discovery'; /** * Safety timeout for quit confirmation from the renderer. @@ -245,6 +246,7 @@ export function createQuitHandler(deps: QuitHandlerDependencies): QuitHandler { webServer?.stop().catch((err: unknown) => { logger.error(`Error stopping web server: ${err}`, 'Shutdown'); }); + deleteCliServerInfo(); // Close stats database logger.info('Closing stats database', 'Shutdown'); diff --git a/src/main/ipc/handlers/web.ts b/src/main/ipc/handlers/web.ts index 482cca772e..ea35261b9d 100644 --- a/src/main/ipc/handlers/web.ts +++ b/src/main/ipc/handlers/web.ts @@ -27,7 +27,7 @@ import { logger } from '../../utils/logger'; import { WebServer } from '../../web-server'; import type { AITabData } from '../../web-server/services/broadcastService'; import type { SettingsStoreInterface } from '../../stores/types'; -import { writeCliServerInfo } from '../../../shared/cli-server-discovery'; +import { deleteCliServerInfo, writeCliServerInfo } from '../../../shared/cli-server-discovery'; /** * Timeout for waiting for web server to become active (ms) @@ -265,6 +265,7 @@ export function registerWebHandlers(deps: WebHandlerDependencies): void { try { logger.info('Stopping web server', 'WebServer'); await webServer.stop(); + deleteCliServerInfo(); setWebServer(null); // Allow garbage collection, will recreate on next start logger.info('Web server stopped and cleaned up', 'WebServer'); return { success: true }; @@ -345,6 +346,7 @@ export function registerWebHandlers(deps: WebHandlerDependencies): void { try { logger.info(`Disabled ${count} live sessions, stopping server`, 'Live'); await webServer.stop(); + deleteCliServerInfo(); setWebServer(null); return { success: true, count }; } catch (error: any) { From 9837da5dee47660dce5ac8d04e9fff160406f26e Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 18:02:52 -0700 Subject: [PATCH 05/24] MAESTRO: Add CLI server discovery tests --- .../shared/cli-server-discovery.test.ts | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 src/__tests__/shared/cli-server-discovery.test.ts diff --git a/src/__tests__/shared/cli-server-discovery.test.ts b/src/__tests__/shared/cli-server-discovery.test.ts new file mode 100644 index 0000000000..ca9161dacf --- /dev/null +++ b/src/__tests__/shared/cli-server-discovery.test.ts @@ -0,0 +1,156 @@ +/** + * Tests for src/shared/cli-server-discovery.ts + * + * This module publishes the Electron app's CLI IPC server discovery file. + * Tests mock Node.js fs, os, and process APIs to isolate behavior. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('fs', () => ({ + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + existsSync: vi.fn(), + mkdirSync: vi.fn(), + renameSync: vi.fn(), + unlinkSync: vi.fn(), +})); + +vi.mock('os', () => ({ + platform: vi.fn(), + homedir: vi.fn(), +})); + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { + CliServerInfo, + deleteCliServerInfo, + isCliServerRunning, + readCliServerInfo, + writeCliServerInfo, +} from '../../shared/cli-server-discovery'; + +const mockFs = { + readFileSync: fs.readFileSync as ReturnType, + writeFileSync: fs.writeFileSync as ReturnType, + existsSync: fs.existsSync as ReturnType, + mkdirSync: fs.mkdirSync as ReturnType, + renameSync: fs.renameSync as ReturnType, + unlinkSync: fs.unlinkSync as ReturnType, +}; + +const mockOs = { + platform: os.platform as ReturnType, + homedir: os.homedir as ReturnType, +}; + +describe('cli-server-discovery', () => { + const configDir = path.join('/Users/testuser', 'Library', 'Application Support', 'maestro'); + const serverFile = path.join(configDir, 'cli-server.json'); + const sampleInfo: CliServerInfo = { + port: 54321, + token: 'test-token', + pid: 12345, + startedAt: 1710000000000, + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockOs.platform.mockReturnValue('darwin'); + mockOs.homedir.mockReturnValue('/Users/testuser'); + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(sampleInfo)); + mockFs.writeFileSync.mockReturnValue(undefined); + mockFs.mkdirSync.mockReturnValue(undefined); + mockFs.renameSync.mockReturnValue(undefined); + mockFs.unlinkSync.mockReturnValue(undefined); + + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('writeCliServerInfo', () => { + it('creates the discovery file with correct content using an atomic rename', () => { + mockFs.existsSync.mockReturnValue(false); + + writeCliServerInfo(sampleInfo); + + expect(mockFs.mkdirSync).toHaveBeenCalledWith(configDir, { recursive: true }); + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + `${serverFile}.tmp`, + JSON.stringify(sampleInfo, null, 2), + 'utf-8' + ); + expect(mockFs.renameSync).toHaveBeenCalledWith(`${serverFile}.tmp`, serverFile); + }); + }); + + describe('readCliServerInfo', () => { + it('returns null for a missing file', () => { + mockFs.readFileSync.mockImplementation(() => { + throw new Error('ENOENT: no such file or directory'); + }); + + expect(readCliServerInfo()).toBeNull(); + }); + + it('returns parsed data for a valid file', () => { + mockFs.readFileSync.mockReturnValue(JSON.stringify(sampleInfo)); + + expect(readCliServerInfo()).toEqual(sampleInfo); + expect(mockFs.readFileSync).toHaveBeenCalledWith(serverFile, 'utf-8'); + }); + + it('returns null for invalid discovery data', () => { + mockFs.readFileSync.mockReturnValue(JSON.stringify({ port: 54321, token: '' })); + + expect(readCliServerInfo()).toBeNull(); + }); + }); + + describe('deleteCliServerInfo', () => { + it('removes the discovery file when it exists', () => { + deleteCliServerInfo(); + + expect(mockFs.unlinkSync).toHaveBeenCalledWith(serverFile); + }); + + it('does not remove the discovery file when it is missing', () => { + mockFs.existsSync.mockReturnValue(false); + + deleteCliServerInfo(); + + expect(mockFs.unlinkSync).not.toHaveBeenCalled(); + }); + }); + + describe('isCliServerRunning', () => { + it('returns true for the current PID', () => { + mockFs.readFileSync.mockReturnValue(JSON.stringify({ ...sampleInfo, pid: process.pid })); + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); + + expect(isCliServerRunning()).toBe(true); + expect(killSpy).toHaveBeenCalledWith(process.pid, 0); + expect(mockFs.unlinkSync).not.toHaveBeenCalled(); + }); + + it('returns false and removes stale discovery info for a non-existent PID', () => { + const missingPid = 999999; + mockFs.readFileSync.mockReturnValue(JSON.stringify({ ...sampleInfo, pid: missingPid })); + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw new Error('ESRCH'); + }); + + expect(isCliServerRunning()).toBe(false); + expect(killSpy).toHaveBeenCalledWith(missingPid, 0); + expect(mockFs.unlinkSync).toHaveBeenCalledWith(serverFile); + }); + }); +}); From aeeabbb5901271f5b2bed48c220a53de80ee5778 Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 18:08:45 -0700 Subject: [PATCH 06/24] MAESTRO: Add open file tab websocket message --- .../handlers/messageHandlers.test.ts | 82 +++++++++++++++++++ .../web-server/web-server-factory.test.ts | 37 +++++++++ src/main/web-server/WebServer.ts | 7 ++ .../web-server/handlers/messageHandlers.ts | 38 +++++++++ .../web-server/managers/CallbackRegistry.ts | 12 +++ src/main/web-server/types.ts | 2 + src/main/web-server/web-server-factory.ts | 15 ++++ 7 files changed, 193 insertions(+) diff --git a/src/__tests__/main/web-server/handlers/messageHandlers.test.ts b/src/__tests__/main/web-server/handlers/messageHandlers.test.ts index df7ed829f5..be540394fb 100644 --- a/src/__tests__/main/web-server/handlers/messageHandlers.test.ts +++ b/src/__tests__/main/web-server/handlers/messageHandlers.test.ts @@ -66,6 +66,10 @@ function createMockCallbacks(): MessageHandlerCallbacks { newTab: vi.fn().mockResolvedValue({ tabId: 'new-tab-123' }), closeTab: vi.fn().mockResolvedValue(true), renameTab: vi.fn().mockResolvedValue(true), + starTab: vi.fn().mockResolvedValue(true), + reorderTab: vi.fn().mockResolvedValue(true), + toggleBookmark: vi.fn().mockResolvedValue(true), + openFileTab: vi.fn().mockResolvedValue(true), getSessions: vi.fn().mockReturnValue([ { id: 'session-1', @@ -536,6 +540,84 @@ describe('WebSocketMessageHandler', () => { }); }); + describe('Open File Tab (Web → Desktop)', () => { + it('should open file tab on desktop', async () => { + handler.handleMessage(client, { + type: 'open_file_tab', + sessionId: 'session-1', + filePath: '/test/project/src/App.tsx', + }); + + await vi.waitFor(() => { + expect(callbacks.openFileTab).toHaveBeenCalledWith( + 'session-1', + '/test/project/src/App.tsx' + ); + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('open_file_tab_result'); + expect(response.success).toBe(true); + expect(response.sessionId).toBe('session-1'); + expect(response.filePath).toBe('/test/project/src/App.tsx'); + }); + + it('should reject open file tab with missing sessionId', () => { + handler.handleMessage(client, { + type: 'open_file_tab', + filePath: '/test/project/src/App.tsx', + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('error'); + expect(response.message).toContain('Missing sessionId or filePath'); + expect(callbacks.openFileTab).not.toHaveBeenCalled(); + }); + + it('should reject open file tab with missing filePath', () => { + handler.handleMessage(client, { + type: 'open_file_tab', + sessionId: 'session-1', + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('error'); + expect(response.message).toContain('Missing sessionId or filePath'); + expect(callbacks.openFileTab).not.toHaveBeenCalled(); + }); + + it('should handle missing openFileTab callback', () => { + const handlerNoCallbacks = new WebSocketMessageHandler(); + + handlerNoCallbacks.handleMessage(client, { + type: 'open_file_tab', + sessionId: 'session-1', + filePath: '/test/project/src/App.tsx', + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('error'); + expect(response.message).toContain('not configured'); + }); + + it('should handle open file tab failure', async () => { + (callbacks.openFileTab as any).mockRejectedValue(new Error('Renderer failed')); + + handler.handleMessage(client, { + type: 'open_file_tab', + sessionId: 'session-1', + filePath: '/test/project/src/App.tsx', + }); + + await vi.waitFor(() => { + const calls = (client.socket.send as any).mock.calls; + const lastResponse = JSON.parse(calls[calls.length - 1][0]); + expect(lastResponse.type).toBe('error'); + expect(lastResponse.message).toContain('Renderer failed'); + }); + }); + }); + describe('Unknown Message Types', () => { it('should echo unknown message types for debugging', () => { handler.handleMessage(client, { diff --git a/src/__tests__/main/web-server/web-server-factory.test.ts b/src/__tests__/main/web-server/web-server-factory.test.ts index 8909026b91..c4f1ac0506 100644 --- a/src/__tests__/main/web-server/web-server-factory.test.ts +++ b/src/__tests__/main/web-server/web-server-factory.test.ts @@ -38,6 +38,7 @@ vi.mock('../../../main/web-server/WebServer', () => { setStarTabCallback = vi.fn(); setReorderTabCallback = vi.fn(); setToggleBookmarkCallback = vi.fn(); + setOpenFileTabCallback = vi.fn(); constructor(port: number, securityToken?: string) { this.port = port; @@ -353,6 +354,10 @@ describe('web-server/web-server-factory', () => { expect(server.setCloseTabCallback).toHaveBeenCalled(); expect(server.setRenameTabCallback).toHaveBeenCalled(); }); + + it('should register openFileTabCallback', () => { + expect(server.setOpenFileTabCallback).toHaveBeenCalled(); + }); }); describe('getSessionsCallback behavior', () => { @@ -496,6 +501,38 @@ describe('web-server/web-server-factory', () => { }); }); + describe('openFileTabCallback behavior', () => { + it('should return false when mainWindow is null', async () => { + deps.getMainWindow = vi.fn().mockReturnValue(null); + const createWebServer = createWebServerFactory(deps); + const server = createWebServer(); + + const setOpenFileTabCallback = server.setOpenFileTabCallback as ReturnType; + const callback = setOpenFileTabCallback.mock.calls[0][0]; + + const result = await callback('session-1', '/test/project/src/App.tsx'); + + expect(result).toBe(false); + }); + + it('should send open file tab to renderer', async () => { + const createWebServer = createWebServerFactory(deps); + const server = createWebServer(); + + const setOpenFileTabCallback = server.setOpenFileTabCallback as ReturnType; + const callback = setOpenFileTabCallback.mock.calls[0][0]; + + const result = await callback('session-1', '/test/project/src/App.tsx'); + + expect(result).toBe(true); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'remote:openFileTab', + 'session-1', + '/test/project/src/App.tsx' + ); + }); + }); + describe('getThemeCallback behavior', () => { it('should return theme from getThemeById', () => { const createWebServer = createWebServerFactory(deps); diff --git a/src/main/web-server/WebServer.ts b/src/main/web-server/WebServer.ts index c335924ee1..8be1df3e49 100644 --- a/src/main/web-server/WebServer.ts +++ b/src/main/web-server/WebServer.ts @@ -63,6 +63,7 @@ import type { StarTabCallback, ReorderTabCallback, ToggleBookmarkCallback, + OpenFileTabCallback, GetThemeCallback, GetBionifyReadingModeCallback, GetCustomCommandsCallback, @@ -344,6 +345,10 @@ export class WebServer { this.callbackRegistry.setToggleBookmarkCallback(callback); } + setOpenFileTabCallback(callback: OpenFileTabCallback): void { + this.callbackRegistry.setOpenFileTabCallback(callback); + } + setGetHistoryCallback(callback: GetHistoryCallback): void { this.callbackRegistry.setGetHistoryCallback(callback); } @@ -495,6 +500,8 @@ export class WebServer { reorderTab: async (sessionId: string, fromIndex: number, toIndex: number) => this.callbackRegistry.reorderTab(sessionId, fromIndex, toIndex), toggleBookmark: async (sessionId: string) => this.callbackRegistry.toggleBookmark(sessionId), + openFileTab: async (sessionId: string, filePath: string) => + this.callbackRegistry.openFileTab(sessionId, filePath), getSessions: () => this.callbackRegistry.getSessions(), getLiveSessionInfo: (sessionId: string) => this.liveSessionManager.getLiveSessionInfo(sessionId), diff --git a/src/main/web-server/handlers/messageHandlers.ts b/src/main/web-server/handlers/messageHandlers.ts index 65c3d25f83..3ed62741f2 100644 --- a/src/main/web-server/handlers/messageHandlers.ts +++ b/src/main/web-server/handlers/messageHandlers.ts @@ -16,6 +16,7 @@ * - new_tab: Create a new tab within a session * - close_tab: Close a tab within a session * - rename_tab: Rename a tab within a session + * - open_file_tab: Open a file preview tab in desktop */ import { WebSocket } from 'ws'; @@ -32,6 +33,7 @@ export interface WebClientMessage { sessionId?: string; tabId?: string; command?: string; + filePath?: string; mode?: 'ai' | 'terminal'; inputMode?: 'ai' | 'terminal'; newName?: string; @@ -85,6 +87,7 @@ export interface MessageHandlerCallbacks { starTab: (sessionId: string, tabId: string, starred: boolean) => Promise; reorderTab: (sessionId: string, fromIndex: number, toIndex: number) => Promise; toggleBookmark: (sessionId: string) => Promise; + openFileTab: (sessionId: string, filePath: string) => Promise; getSessions: () => Array<{ id: string; name: string; @@ -194,6 +197,10 @@ export class WebSocketMessageHandler { this.handleToggleBookmark(client, message); break; + case 'open_file_tab': + this.handleOpenFileTab(client, message); + break; + default: this.handleUnknown(client, message); } @@ -635,6 +642,37 @@ export class WebSocketMessageHandler { }); } + /** + * Handle open_file_tab message - open a file preview tab in desktop + */ + private handleOpenFileTab(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + const filePath = message.filePath as string; + logger.info( + `[Web] Received open_file_tab message: session=${sessionId}, filePath=${filePath}`, + LOG_CONTEXT + ); + + if (!sessionId || !filePath) { + this.sendError(client, 'Missing sessionId or filePath'); + return; + } + + if (!this.callbacks.openFileTab) { + this.sendError(client, 'File tab opening not configured'); + return; + } + + this.callbacks + .openFileTab(sessionId, filePath) + .then((success) => { + this.send(client, { type: 'open_file_tab_result', success, sessionId, filePath }); + }) + .catch((error) => { + this.sendError(client, `Failed to open file tab: ${error.message}`); + }); + } + /** * Handle unknown message types - echo back for debugging */ diff --git a/src/main/web-server/managers/CallbackRegistry.ts b/src/main/web-server/managers/CallbackRegistry.ts index 8373901420..7d7b6961f4 100644 --- a/src/main/web-server/managers/CallbackRegistry.ts +++ b/src/main/web-server/managers/CallbackRegistry.ts @@ -21,6 +21,7 @@ import type { StarTabCallback, ReorderTabCallback, ToggleBookmarkCallback, + OpenFileTabCallback, GetThemeCallback, GetBionifyReadingModeCallback, GetCustomCommandsCallback, @@ -50,6 +51,7 @@ export interface WebServerCallbacks { starTab: StarTabCallback | null; reorderTab: ReorderTabCallback | null; toggleBookmark: ToggleBookmarkCallback | null; + openFileTab: OpenFileTabCallback | null; getHistory: GetHistoryCallback | null; } @@ -72,6 +74,7 @@ export class CallbackRegistry { starTab: null, reorderTab: null, toggleBookmark: null, + openFileTab: null, getHistory: null, }; @@ -159,6 +162,11 @@ export class CallbackRegistry { return this.callbacks.toggleBookmark(sessionId); } + async openFileTab(sessionId: string, filePath: string): Promise { + if (!this.callbacks.openFileTab) return false; + return this.callbacks.openFileTab(sessionId, filePath); + } + getHistory(projectPath?: string, sessionId?: string): ReturnType | [] { return this.callbacks.getHistory?.(projectPath, sessionId) ?? []; } @@ -239,6 +247,10 @@ export class CallbackRegistry { this.callbacks.toggleBookmark = callback; } + setOpenFileTabCallback(callback: OpenFileTabCallback): void { + this.callbacks.openFileTab = callback; + } + setGetHistoryCallback(callback: GetHistoryCallback): void { this.callbacks.getHistory = callback; } diff --git a/src/main/web-server/types.ts b/src/main/web-server/types.ts index 1eb2001ed4..9ccf89c816 100644 --- a/src/main/web-server/types.ts +++ b/src/main/web-server/types.ts @@ -207,6 +207,7 @@ export interface WebClientMessage { sessionId?: string; tabId?: string; command?: string; + filePath?: string; mode?: 'ai' | 'terminal'; inputMode?: 'ai' | 'terminal'; newName?: string; @@ -287,6 +288,7 @@ export type ReorderTabCallback = ( toIndex: number ) => Promise; export type ToggleBookmarkCallback = (sessionId: string) => Promise; +export type OpenFileTabCallback = (sessionId: string, filePath: string) => Promise; /** * Callback type for fetching current theme. diff --git a/src/main/web-server/web-server-factory.ts b/src/main/web-server/web-server-factory.ts index f7b9a7f436..4fefba3e63 100644 --- a/src/main/web-server/web-server-factory.ts +++ b/src/main/web-server/web-server-factory.ts @@ -514,6 +514,21 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) { return true; }); + server.setOpenFileTabCallback(async (sessionId: string, filePath: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for openFileTab', 'WebServer'); + return false; + } + + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for openFileTab', 'WebServer'); + return false; + } + mainWindow.webContents.send('remote:openFileTab', sessionId, filePath); + return true; + }); + return server; }; } From d8442b0580ab5c3b023c66fa91657a31687ce855 Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 18:19:47 -0700 Subject: [PATCH 07/24] MAESTRO: Add refresh file tree websocket IPC --- .../handlers/messageHandlers.test.ts | 59 +++++++++++++++++++ .../web-server/web-server-factory.test.ts | 37 ++++++++++++ src/main/web-server/WebServer.ts | 7 +++ .../web-server/handlers/messageHandlers.ts | 33 +++++++++++ .../web-server/managers/CallbackRegistry.ts | 12 ++++ src/main/web-server/types.ts | 1 + src/main/web-server/web-server-factory.ts | 15 +++++ 7 files changed, 164 insertions(+) diff --git a/src/__tests__/main/web-server/handlers/messageHandlers.test.ts b/src/__tests__/main/web-server/handlers/messageHandlers.test.ts index be540394fb..dfa84bd50e 100644 --- a/src/__tests__/main/web-server/handlers/messageHandlers.test.ts +++ b/src/__tests__/main/web-server/handlers/messageHandlers.test.ts @@ -70,6 +70,7 @@ function createMockCallbacks(): MessageHandlerCallbacks { reorderTab: vi.fn().mockResolvedValue(true), toggleBookmark: vi.fn().mockResolvedValue(true), openFileTab: vi.fn().mockResolvedValue(true), + refreshFileTree: vi.fn().mockResolvedValue(true), getSessions: vi.fn().mockReturnValue([ { id: 'session-1', @@ -618,6 +619,64 @@ describe('WebSocketMessageHandler', () => { }); }); + describe('Refresh File Tree (Web → Desktop)', () => { + it('should refresh file tree on desktop', async () => { + handler.handleMessage(client, { + type: 'refresh_file_tree', + sessionId: 'session-1', + }); + + await vi.waitFor(() => { + expect(callbacks.refreshFileTree).toHaveBeenCalledWith('session-1'); + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('refresh_file_tree_result'); + expect(response.success).toBe(true); + expect(response.sessionId).toBe('session-1'); + }); + + it('should reject refresh file tree with missing sessionId', () => { + handler.handleMessage(client, { + type: 'refresh_file_tree', + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('error'); + expect(response.message).toContain('Missing sessionId'); + expect(callbacks.refreshFileTree).not.toHaveBeenCalled(); + }); + + it('should handle missing refreshFileTree callback', () => { + const handlerNoCallbacks = new WebSocketMessageHandler(); + + handlerNoCallbacks.handleMessage(client, { + type: 'refresh_file_tree', + sessionId: 'session-1', + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('error'); + expect(response.message).toContain('not configured'); + }); + + it('should handle refresh file tree failure', async () => { + (callbacks.refreshFileTree as any).mockRejectedValue(new Error('Renderer failed')); + + handler.handleMessage(client, { + type: 'refresh_file_tree', + sessionId: 'session-1', + }); + + await vi.waitFor(() => { + const calls = (client.socket.send as any).mock.calls; + const lastResponse = JSON.parse(calls[calls.length - 1][0]); + expect(lastResponse.type).toBe('error'); + expect(lastResponse.message).toContain('Renderer failed'); + }); + }); + }); + describe('Unknown Message Types', () => { it('should echo unknown message types for debugging', () => { handler.handleMessage(client, { diff --git a/src/__tests__/main/web-server/web-server-factory.test.ts b/src/__tests__/main/web-server/web-server-factory.test.ts index c4f1ac0506..d04eed608d 100644 --- a/src/__tests__/main/web-server/web-server-factory.test.ts +++ b/src/__tests__/main/web-server/web-server-factory.test.ts @@ -39,6 +39,7 @@ vi.mock('../../../main/web-server/WebServer', () => { setReorderTabCallback = vi.fn(); setToggleBookmarkCallback = vi.fn(); setOpenFileTabCallback = vi.fn(); + setRefreshFileTreeCallback = vi.fn(); constructor(port: number, securityToken?: string) { this.port = port; @@ -358,6 +359,10 @@ describe('web-server/web-server-factory', () => { it('should register openFileTabCallback', () => { expect(server.setOpenFileTabCallback).toHaveBeenCalled(); }); + + it('should register refreshFileTreeCallback', () => { + expect(server.setRefreshFileTreeCallback).toHaveBeenCalled(); + }); }); describe('getSessionsCallback behavior', () => { @@ -533,6 +538,38 @@ describe('web-server/web-server-factory', () => { }); }); + describe('refreshFileTreeCallback behavior', () => { + it('should return false when mainWindow is null', async () => { + deps.getMainWindow = vi.fn().mockReturnValue(null); + const createWebServer = createWebServerFactory(deps); + const server = createWebServer(); + + const setRefreshFileTreeCallback = server.setRefreshFileTreeCallback as ReturnType< + typeof vi.fn + >; + const callback = setRefreshFileTreeCallback.mock.calls[0][0]; + + const result = await callback('session-1'); + + expect(result).toBe(false); + }); + + it('should send refresh file tree to renderer', async () => { + const createWebServer = createWebServerFactory(deps); + const server = createWebServer(); + + const setRefreshFileTreeCallback = server.setRefreshFileTreeCallback as ReturnType< + typeof vi.fn + >; + const callback = setRefreshFileTreeCallback.mock.calls[0][0]; + + const result = await callback('session-1'); + + expect(result).toBe(true); + expect(mockWebContents.send).toHaveBeenCalledWith('remote:refreshFileTree', 'session-1'); + }); + }); + describe('getThemeCallback behavior', () => { it('should return theme from getThemeById', () => { const createWebServer = createWebServerFactory(deps); diff --git a/src/main/web-server/WebServer.ts b/src/main/web-server/WebServer.ts index 8be1df3e49..069a10397f 100644 --- a/src/main/web-server/WebServer.ts +++ b/src/main/web-server/WebServer.ts @@ -64,6 +64,7 @@ import type { ReorderTabCallback, ToggleBookmarkCallback, OpenFileTabCallback, + RefreshFileTreeCallback, GetThemeCallback, GetBionifyReadingModeCallback, GetCustomCommandsCallback, @@ -349,6 +350,10 @@ export class WebServer { this.callbackRegistry.setOpenFileTabCallback(callback); } + setRefreshFileTreeCallback(callback: RefreshFileTreeCallback): void { + this.callbackRegistry.setRefreshFileTreeCallback(callback); + } + setGetHistoryCallback(callback: GetHistoryCallback): void { this.callbackRegistry.setGetHistoryCallback(callback); } @@ -502,6 +507,8 @@ export class WebServer { toggleBookmark: async (sessionId: string) => this.callbackRegistry.toggleBookmark(sessionId), openFileTab: async (sessionId: string, filePath: string) => this.callbackRegistry.openFileTab(sessionId, filePath), + refreshFileTree: async (sessionId: string) => + this.callbackRegistry.refreshFileTree(sessionId), getSessions: () => this.callbackRegistry.getSessions(), getLiveSessionInfo: (sessionId: string) => this.liveSessionManager.getLiveSessionInfo(sessionId), diff --git a/src/main/web-server/handlers/messageHandlers.ts b/src/main/web-server/handlers/messageHandlers.ts index 3ed62741f2..24c89892b5 100644 --- a/src/main/web-server/handlers/messageHandlers.ts +++ b/src/main/web-server/handlers/messageHandlers.ts @@ -17,6 +17,7 @@ * - close_tab: Close a tab within a session * - rename_tab: Rename a tab within a session * - open_file_tab: Open a file preview tab in desktop + * - refresh_file_tree: Refresh the file tree in desktop */ import { WebSocket } from 'ws'; @@ -88,6 +89,7 @@ export interface MessageHandlerCallbacks { reorderTab: (sessionId: string, fromIndex: number, toIndex: number) => Promise; toggleBookmark: (sessionId: string) => Promise; openFileTab: (sessionId: string, filePath: string) => Promise; + refreshFileTree: (sessionId: string) => Promise; getSessions: () => Array<{ id: string; name: string; @@ -201,6 +203,10 @@ export class WebSocketMessageHandler { this.handleOpenFileTab(client, message); break; + case 'refresh_file_tree': + this.handleRefreshFileTree(client, message); + break; + default: this.handleUnknown(client, message); } @@ -673,6 +679,33 @@ export class WebSocketMessageHandler { }); } + /** + * Handle refresh_file_tree message - refresh the file tree in desktop + */ + private handleRefreshFileTree(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + logger.info(`[Web] Received refresh_file_tree message: session=${sessionId}`, LOG_CONTEXT); + + if (!sessionId) { + this.sendError(client, 'Missing sessionId'); + return; + } + + if (!this.callbacks.refreshFileTree) { + this.sendError(client, 'File tree refresh not configured'); + return; + } + + this.callbacks + .refreshFileTree(sessionId) + .then((success) => { + this.send(client, { type: 'refresh_file_tree_result', success, sessionId }); + }) + .catch((error) => { + this.sendError(client, `Failed to refresh file tree: ${error.message}`); + }); + } + /** * Handle unknown message types - echo back for debugging */ diff --git a/src/main/web-server/managers/CallbackRegistry.ts b/src/main/web-server/managers/CallbackRegistry.ts index 7d7b6961f4..56dd82afa9 100644 --- a/src/main/web-server/managers/CallbackRegistry.ts +++ b/src/main/web-server/managers/CallbackRegistry.ts @@ -22,6 +22,7 @@ import type { ReorderTabCallback, ToggleBookmarkCallback, OpenFileTabCallback, + RefreshFileTreeCallback, GetThemeCallback, GetBionifyReadingModeCallback, GetCustomCommandsCallback, @@ -52,6 +53,7 @@ export interface WebServerCallbacks { reorderTab: ReorderTabCallback | null; toggleBookmark: ToggleBookmarkCallback | null; openFileTab: OpenFileTabCallback | null; + refreshFileTree: RefreshFileTreeCallback | null; getHistory: GetHistoryCallback | null; } @@ -75,6 +77,7 @@ export class CallbackRegistry { reorderTab: null, toggleBookmark: null, openFileTab: null, + refreshFileTree: null, getHistory: null, }; @@ -167,6 +170,11 @@ export class CallbackRegistry { return this.callbacks.openFileTab(sessionId, filePath); } + async refreshFileTree(sessionId: string): Promise { + if (!this.callbacks.refreshFileTree) return false; + return this.callbacks.refreshFileTree(sessionId); + } + getHistory(projectPath?: string, sessionId?: string): ReturnType | [] { return this.callbacks.getHistory?.(projectPath, sessionId) ?? []; } @@ -251,6 +259,10 @@ export class CallbackRegistry { this.callbacks.openFileTab = callback; } + setRefreshFileTreeCallback(callback: RefreshFileTreeCallback): void { + this.callbacks.refreshFileTree = callback; + } + setGetHistoryCallback(callback: GetHistoryCallback): void { this.callbacks.getHistory = callback; } diff --git a/src/main/web-server/types.ts b/src/main/web-server/types.ts index 9ccf89c816..ee11cafe89 100644 --- a/src/main/web-server/types.ts +++ b/src/main/web-server/types.ts @@ -289,6 +289,7 @@ export type ReorderTabCallback = ( ) => Promise; export type ToggleBookmarkCallback = (sessionId: string) => Promise; export type OpenFileTabCallback = (sessionId: string, filePath: string) => Promise; +export type RefreshFileTreeCallback = (sessionId: string) => Promise; /** * Callback type for fetching current theme. diff --git a/src/main/web-server/web-server-factory.ts b/src/main/web-server/web-server-factory.ts index 4fefba3e63..c9d8720f5b 100644 --- a/src/main/web-server/web-server-factory.ts +++ b/src/main/web-server/web-server-factory.ts @@ -529,6 +529,21 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) { return true; }); + server.setRefreshFileTreeCallback(async (sessionId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for refreshFileTree', 'WebServer'); + return false; + } + + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for refreshFileTree', 'WebServer'); + return false; + } + mainWindow.webContents.send('remote:refreshFileTree', sessionId); + return true; + }); + return server; }; } From ec2b9b804295c113095ea2c0573169e2c076a707 Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 18:28:45 -0700 Subject: [PATCH 08/24] MAESTRO: Add refresh auto run docs WebSocket message --- .../handlers/messageHandlers.test.ts | 59 +++++++++++++++++++ .../web-server/web-server-factory.test.ts | 37 ++++++++++++ src/main/web-server/WebServer.ts | 7 +++ .../web-server/handlers/messageHandlers.ts | 33 +++++++++++ .../web-server/managers/CallbackRegistry.ts | 12 ++++ src/main/web-server/types.ts | 1 + src/main/web-server/web-server-factory.ts | 15 +++++ 7 files changed, 164 insertions(+) diff --git a/src/__tests__/main/web-server/handlers/messageHandlers.test.ts b/src/__tests__/main/web-server/handlers/messageHandlers.test.ts index dfa84bd50e..b419578599 100644 --- a/src/__tests__/main/web-server/handlers/messageHandlers.test.ts +++ b/src/__tests__/main/web-server/handlers/messageHandlers.test.ts @@ -71,6 +71,7 @@ function createMockCallbacks(): MessageHandlerCallbacks { toggleBookmark: vi.fn().mockResolvedValue(true), openFileTab: vi.fn().mockResolvedValue(true), refreshFileTree: vi.fn().mockResolvedValue(true), + refreshAutoRunDocs: vi.fn().mockResolvedValue(true), getSessions: vi.fn().mockReturnValue([ { id: 'session-1', @@ -677,6 +678,64 @@ describe('WebSocketMessageHandler', () => { }); }); + describe('Refresh Auto Run Docs (Web → Desktop)', () => { + it('should refresh Auto Run docs on desktop', async () => { + handler.handleMessage(client, { + type: 'refresh_auto_run_docs', + sessionId: 'session-1', + }); + + await vi.waitFor(() => { + expect(callbacks.refreshAutoRunDocs).toHaveBeenCalledWith('session-1'); + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('refresh_auto_run_docs_result'); + expect(response.success).toBe(true); + expect(response.sessionId).toBe('session-1'); + }); + + it('should reject refresh Auto Run docs with missing sessionId', () => { + handler.handleMessage(client, { + type: 'refresh_auto_run_docs', + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('error'); + expect(response.message).toContain('Missing sessionId'); + expect(callbacks.refreshAutoRunDocs).not.toHaveBeenCalled(); + }); + + it('should handle missing refreshAutoRunDocs callback', () => { + const handlerNoCallbacks = new WebSocketMessageHandler(); + + handlerNoCallbacks.handleMessage(client, { + type: 'refresh_auto_run_docs', + sessionId: 'session-1', + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('error'); + expect(response.message).toContain('not configured'); + }); + + it('should handle refresh Auto Run docs failure', async () => { + (callbacks.refreshAutoRunDocs as any).mockRejectedValue(new Error('Renderer failed')); + + handler.handleMessage(client, { + type: 'refresh_auto_run_docs', + sessionId: 'session-1', + }); + + await vi.waitFor(() => { + const calls = (client.socket.send as any).mock.calls; + const lastResponse = JSON.parse(calls[calls.length - 1][0]); + expect(lastResponse.type).toBe('error'); + expect(lastResponse.message).toContain('Renderer failed'); + }); + }); + }); + describe('Unknown Message Types', () => { it('should echo unknown message types for debugging', () => { handler.handleMessage(client, { diff --git a/src/__tests__/main/web-server/web-server-factory.test.ts b/src/__tests__/main/web-server/web-server-factory.test.ts index d04eed608d..3fa364a53b 100644 --- a/src/__tests__/main/web-server/web-server-factory.test.ts +++ b/src/__tests__/main/web-server/web-server-factory.test.ts @@ -40,6 +40,7 @@ vi.mock('../../../main/web-server/WebServer', () => { setToggleBookmarkCallback = vi.fn(); setOpenFileTabCallback = vi.fn(); setRefreshFileTreeCallback = vi.fn(); + setRefreshAutoRunDocsCallback = vi.fn(); constructor(port: number, securityToken?: string) { this.port = port; @@ -363,6 +364,10 @@ describe('web-server/web-server-factory', () => { it('should register refreshFileTreeCallback', () => { expect(server.setRefreshFileTreeCallback).toHaveBeenCalled(); }); + + it('should register refreshAutoRunDocsCallback', () => { + expect(server.setRefreshAutoRunDocsCallback).toHaveBeenCalled(); + }); }); describe('getSessionsCallback behavior', () => { @@ -570,6 +575,38 @@ describe('web-server/web-server-factory', () => { }); }); + describe('refreshAutoRunDocsCallback behavior', () => { + it('should return false when mainWindow is null', async () => { + deps.getMainWindow = vi.fn().mockReturnValue(null); + const createWebServer = createWebServerFactory(deps); + const server = createWebServer(); + + const setRefreshAutoRunDocsCallback = server.setRefreshAutoRunDocsCallback as ReturnType< + typeof vi.fn + >; + const callback = setRefreshAutoRunDocsCallback.mock.calls[0][0]; + + const result = await callback('session-1'); + + expect(result).toBe(false); + }); + + it('should send refresh Auto Run docs to renderer', async () => { + const createWebServer = createWebServerFactory(deps); + const server = createWebServer(); + + const setRefreshAutoRunDocsCallback = server.setRefreshAutoRunDocsCallback as ReturnType< + typeof vi.fn + >; + const callback = setRefreshAutoRunDocsCallback.mock.calls[0][0]; + + const result = await callback('session-1'); + + expect(result).toBe(true); + expect(mockWebContents.send).toHaveBeenCalledWith('remote:refreshAutoRunDocs', 'session-1'); + }); + }); + describe('getThemeCallback behavior', () => { it('should return theme from getThemeById', () => { const createWebServer = createWebServerFactory(deps); diff --git a/src/main/web-server/WebServer.ts b/src/main/web-server/WebServer.ts index 069a10397f..a6063b90b2 100644 --- a/src/main/web-server/WebServer.ts +++ b/src/main/web-server/WebServer.ts @@ -65,6 +65,7 @@ import type { ToggleBookmarkCallback, OpenFileTabCallback, RefreshFileTreeCallback, + RefreshAutoRunDocsCallback, GetThemeCallback, GetBionifyReadingModeCallback, GetCustomCommandsCallback, @@ -354,6 +355,10 @@ export class WebServer { this.callbackRegistry.setRefreshFileTreeCallback(callback); } + setRefreshAutoRunDocsCallback(callback: RefreshAutoRunDocsCallback): void { + this.callbackRegistry.setRefreshAutoRunDocsCallback(callback); + } + setGetHistoryCallback(callback: GetHistoryCallback): void { this.callbackRegistry.setGetHistoryCallback(callback); } @@ -509,6 +514,8 @@ export class WebServer { this.callbackRegistry.openFileTab(sessionId, filePath), refreshFileTree: async (sessionId: string) => this.callbackRegistry.refreshFileTree(sessionId), + refreshAutoRunDocs: async (sessionId: string) => + this.callbackRegistry.refreshAutoRunDocs(sessionId), getSessions: () => this.callbackRegistry.getSessions(), getLiveSessionInfo: (sessionId: string) => this.liveSessionManager.getLiveSessionInfo(sessionId), diff --git a/src/main/web-server/handlers/messageHandlers.ts b/src/main/web-server/handlers/messageHandlers.ts index 24c89892b5..acb271416a 100644 --- a/src/main/web-server/handlers/messageHandlers.ts +++ b/src/main/web-server/handlers/messageHandlers.ts @@ -18,6 +18,7 @@ * - rename_tab: Rename a tab within a session * - open_file_tab: Open a file preview tab in desktop * - refresh_file_tree: Refresh the file tree in desktop + * - refresh_auto_run_docs: Refresh Auto Run documents in desktop */ import { WebSocket } from 'ws'; @@ -90,6 +91,7 @@ export interface MessageHandlerCallbacks { toggleBookmark: (sessionId: string) => Promise; openFileTab: (sessionId: string, filePath: string) => Promise; refreshFileTree: (sessionId: string) => Promise; + refreshAutoRunDocs: (sessionId: string) => Promise; getSessions: () => Array<{ id: string; name: string; @@ -207,6 +209,10 @@ export class WebSocketMessageHandler { this.handleRefreshFileTree(client, message); break; + case 'refresh_auto_run_docs': + this.handleRefreshAutoRunDocs(client, message); + break; + default: this.handleUnknown(client, message); } @@ -706,6 +712,33 @@ export class WebSocketMessageHandler { }); } + /** + * Handle refresh_auto_run_docs message - refresh Auto Run documents in desktop + */ + private handleRefreshAutoRunDocs(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + logger.info(`[Web] Received refresh_auto_run_docs message: session=${sessionId}`, LOG_CONTEXT); + + if (!sessionId) { + this.sendError(client, 'Missing sessionId'); + return; + } + + if (!this.callbacks.refreshAutoRunDocs) { + this.sendError(client, 'Auto Run docs refresh not configured'); + return; + } + + this.callbacks + .refreshAutoRunDocs(sessionId) + .then((success) => { + this.send(client, { type: 'refresh_auto_run_docs_result', success, sessionId }); + }) + .catch((error) => { + this.sendError(client, `Failed to refresh Auto Run docs: ${error.message}`); + }); + } + /** * Handle unknown message types - echo back for debugging */ diff --git a/src/main/web-server/managers/CallbackRegistry.ts b/src/main/web-server/managers/CallbackRegistry.ts index 56dd82afa9..1144af2ca6 100644 --- a/src/main/web-server/managers/CallbackRegistry.ts +++ b/src/main/web-server/managers/CallbackRegistry.ts @@ -23,6 +23,7 @@ import type { ToggleBookmarkCallback, OpenFileTabCallback, RefreshFileTreeCallback, + RefreshAutoRunDocsCallback, GetThemeCallback, GetBionifyReadingModeCallback, GetCustomCommandsCallback, @@ -54,6 +55,7 @@ export interface WebServerCallbacks { toggleBookmark: ToggleBookmarkCallback | null; openFileTab: OpenFileTabCallback | null; refreshFileTree: RefreshFileTreeCallback | null; + refreshAutoRunDocs: RefreshAutoRunDocsCallback | null; getHistory: GetHistoryCallback | null; } @@ -78,6 +80,7 @@ export class CallbackRegistry { toggleBookmark: null, openFileTab: null, refreshFileTree: null, + refreshAutoRunDocs: null, getHistory: null, }; @@ -175,6 +178,11 @@ export class CallbackRegistry { return this.callbacks.refreshFileTree(sessionId); } + async refreshAutoRunDocs(sessionId: string): Promise { + if (!this.callbacks.refreshAutoRunDocs) return false; + return this.callbacks.refreshAutoRunDocs(sessionId); + } + getHistory(projectPath?: string, sessionId?: string): ReturnType | [] { return this.callbacks.getHistory?.(projectPath, sessionId) ?? []; } @@ -263,6 +271,10 @@ export class CallbackRegistry { this.callbacks.refreshFileTree = callback; } + setRefreshAutoRunDocsCallback(callback: RefreshAutoRunDocsCallback): void { + this.callbacks.refreshAutoRunDocs = callback; + } + setGetHistoryCallback(callback: GetHistoryCallback): void { this.callbacks.getHistory = callback; } diff --git a/src/main/web-server/types.ts b/src/main/web-server/types.ts index ee11cafe89..98f414c78f 100644 --- a/src/main/web-server/types.ts +++ b/src/main/web-server/types.ts @@ -290,6 +290,7 @@ export type ReorderTabCallback = ( export type ToggleBookmarkCallback = (sessionId: string) => Promise; export type OpenFileTabCallback = (sessionId: string, filePath: string) => Promise; export type RefreshFileTreeCallback = (sessionId: string) => Promise; +export type RefreshAutoRunDocsCallback = (sessionId: string) => Promise; /** * Callback type for fetching current theme. diff --git a/src/main/web-server/web-server-factory.ts b/src/main/web-server/web-server-factory.ts index c9d8720f5b..ac769ff05a 100644 --- a/src/main/web-server/web-server-factory.ts +++ b/src/main/web-server/web-server-factory.ts @@ -544,6 +544,21 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) { return true; }); + server.setRefreshAutoRunDocsCallback(async (sessionId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for refreshAutoRunDocs', 'WebServer'); + return false; + } + + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for refreshAutoRunDocs', 'WebServer'); + return false; + } + mainWindow.webContents.send('remote:refreshAutoRunDocs', sessionId); + return true; + }); + return server; }; } From 92337323e1c8cfea91096462d70e95598d7ceaff Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 19:06:08 -0700 Subject: [PATCH 09/24] MAESTRO: Add remote preload listener declarations --- src/main/preload/process.ts | 30 ++++++++++++++++++++++++++++++ src/renderer/global.d.ts | 3 +++ 2 files changed, 33 insertions(+) diff --git a/src/main/preload/process.ts b/src/main/preload/process.ts index 01e47ae794..23836d7ad1 100644 --- a/src/main/preload/process.ts +++ b/src/main/preload/process.ts @@ -416,6 +416,36 @@ export function createProcessApi() { return () => ipcRenderer.removeListener('remote:toggleBookmark', handler); }, + /** + * Subscribe to remote file tab open from web interface + */ + onRemoteOpenFileTab: ( + callback: (sessionId: string, filePath: string) => void + ): (() => void) => { + const handler = (_: unknown, sessionId: string, filePath: string) => + callback(sessionId, filePath); + ipcRenderer.on('remote:openFileTab', handler); + return () => ipcRenderer.removeListener('remote:openFileTab', handler); + }, + + /** + * Subscribe to remote file tree refresh from web interface + */ + onRemoteRefreshFileTree: (callback: (sessionId: string) => void): (() => void) => { + const handler = (_: unknown, sessionId: string) => callback(sessionId); + ipcRenderer.on('remote:refreshFileTree', handler); + return () => ipcRenderer.removeListener('remote:refreshFileTree', handler); + }, + + /** + * Subscribe to remote Auto Run document refresh from web interface + */ + onRemoteRefreshAutoRunDocs: (callback: (sessionId: string) => void): (() => void) => { + const handler = (_: unknown, sessionId: string) => callback(sessionId); + ipcRenderer.on('remote:refreshAutoRunDocs', handler); + return () => ipcRenderer.removeListener('remote:refreshAutoRunDocs', handler); + }, + /** * Subscribe to stderr from runCommand (separate stream) */ diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 64ad5c99d9..0e71a3ddb7 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -321,6 +321,9 @@ interface MaestroAPI { callback: (sessionId: string, fromIndex: number, toIndex: number) => void ) => () => void; onRemoteToggleBookmark: (callback: (sessionId: string) => void) => () => void; + onRemoteOpenFileTab: (callback: (sessionId: string, filePath: string) => void) => () => void; + onRemoteRefreshFileTree: (callback: (sessionId: string) => void) => () => void; + onRemoteRefreshAutoRunDocs: (callback: (sessionId: string) => void) => () => void; onStderr: (callback: (sessionId: string, data: string) => void) => () => void; onCommandExit: (callback: (sessionId: string, code: number) => void) => () => void; onUsage: (callback: (sessionId: string, usageStats: UsageStats) => void) => () => void; From 933baf42cef801cf45b528883e19125d87711746 Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 19:11:59 -0700 Subject: [PATCH 10/24] MAESTRO: Wire renderer CLI IPC handlers --- .../hooks/useRemoteIntegration.test.ts | 80 +++++++++++++++++++ src/renderer/App.tsx | 72 +++++++++++++++++ .../hooks/remote/useRemoteIntegration.ts | 51 ++++++++++++ 3 files changed, 203 insertions(+) diff --git a/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts b/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts index 55715e8767..452a104e7d 100644 --- a/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts +++ b/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts @@ -73,6 +73,9 @@ describe('useRemoteIntegration', () => { | ((sessionId: string, fromIndex: number, toIndex: number) => void) | undefined; let onRemoteToggleBookmarkHandler: ((sessionId: string) => void) | undefined; + let onRemoteOpenFileTabHandler: ((sessionId: string, filePath: string) => void) | undefined; + let onRemoteRefreshFileTreeHandler: ((sessionId: string) => void) | undefined; + let onRemoteRefreshAutoRunDocsHandler: ((sessionId: string) => void) | undefined; const mockProcess = { ...window.maestro.process, @@ -121,6 +124,18 @@ describe('useRemoteIntegration', () => { onRemoteToggleBookmarkHandler = handler; return () => {}; }), + onRemoteOpenFileTab: vi.fn().mockImplementation((handler) => { + onRemoteOpenFileTabHandler = handler; + return () => {}; + }), + onRemoteRefreshFileTree: vi.fn().mockImplementation((handler) => { + onRemoteRefreshFileTreeHandler = handler; + return () => {}; + }), + onRemoteRefreshAutoRunDocs: vi.fn().mockImplementation((handler) => { + onRemoteRefreshAutoRunDocsHandler = handler; + return () => {}; + }), sendRemoteNewTabResponse: vi.fn(), }; @@ -164,6 +179,9 @@ describe('useRemoteIntegration', () => { onRemoteStarTabHandler = undefined; onRemoteReorderTabHandler = undefined; onRemoteToggleBookmarkHandler = undefined; + onRemoteOpenFileTabHandler = undefined; + onRemoteRefreshFileTreeHandler = undefined; + onRemoteRefreshAutoRunDocsHandler = undefined; window.maestro = { ...originalMaestro, @@ -447,6 +465,68 @@ describe('useRemoteIntegration', () => { }); }); + describe('remote CLI IPC events', () => { + it('dispatches maestro:openFileTab when remote open file tab is received', () => { + const deps = createDeps(); + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent'); + + renderHook(() => useRemoteIntegration(deps)); + + act(() => { + onRemoteOpenFileTabHandler?.('session-1', '/test/project/file.ts'); + }); + + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'maestro:openFileTab', + detail: { sessionId: 'session-1', filePath: '/test/project/file.ts' }, + }) + ); + + dispatchEventSpy.mockRestore(); + }); + + it('dispatches maestro:refreshFileTree when remote file tree refresh is received', () => { + const deps = createDeps(); + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent'); + + renderHook(() => useRemoteIntegration(deps)); + + act(() => { + onRemoteRefreshFileTreeHandler?.('session-1'); + }); + + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'maestro:refreshFileTree', + detail: { sessionId: 'session-1' }, + }) + ); + + dispatchEventSpy.mockRestore(); + }); + + it('dispatches maestro:refreshAutoRunDocs when remote Auto Run docs refresh is received', () => { + const deps = createDeps(); + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent'); + + renderHook(() => useRemoteIntegration(deps)); + + act(() => { + onRemoteRefreshAutoRunDocsHandler?.('session-1'); + }); + + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'maestro:refreshAutoRunDocs', + detail: { sessionId: 'session-1' }, + }) + ); + + dispatchEventSpy.mockRestore(); + }); + }); + describe('remote session selection', () => { it('switches to selected session', () => { const session = createMockSession({ id: 'session-1' }); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 13b3a895e9..079e6fb406 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1775,6 +1775,78 @@ function MaestroConsoleInner() { localHonorGitignore: settings.localHonorGitignore, }); + // 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) { + console.error('[Remote] Failed to open file tab:', error); + } + }; + + 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(); + }; + + window.addEventListener('maestro:openFileTab', handleRemoteOpenFileTab); + window.addEventListener('maestro:refreshFileTree', handleRemoteRefreshFileTree); + window.addEventListener('maestro:refreshAutoRunDocs', handleRemoteRefreshAutoRunDocs); + + return () => { + window.removeEventListener('maestro:openFileTab', handleRemoteOpenFileTab); + window.removeEventListener('maestro:refreshFileTree', handleRemoteRefreshFileTree); + window.removeEventListener('maestro:refreshAutoRunDocs', handleRemoteRefreshAutoRunDocs); + }; + }, [ + activeSessionIdRef, + handleAutoRunRefresh, + handleOpenFileTab, + refreshFileTree, + setActiveFocus, + setActiveSessionId, + sessionsRef, + ]); + // --- FILE EXPLORER EFFECTS --- // Extracted hook for file explorer side effects and keyboard navigation (Phase 2.6) const { stableFileTree, handleMainPanelFileClick } = useFileExplorerEffects({ diff --git a/src/renderer/hooks/remote/useRemoteIntegration.ts b/src/renderer/hooks/remote/useRemoteIntegration.ts index 28e13a7a08..3c0fd71095 100644 --- a/src/renderer/hooks/remote/useRemoteIntegration.ts +++ b/src/renderer/hooks/remote/useRemoteIntegration.ts @@ -182,6 +182,57 @@ 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 interrupts from web interface // This allows web interrupts to go through the same code path as desktop (handleInterrupt) useEffect(() => { From 071441d045eb3b27bf54e28112f997215a09be52 Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 19:21:40 -0700 Subject: [PATCH 11/24] MAESTRO: Focus desktop on select session requests --- .../handlers/messageHandlers.test.ts | 16 ++++++- .../managers/CallbackRegistry.test.ts | 13 +++++- .../web-server/web-server-factory.test.ts | 42 +++++++++++++++++++ src/main/web-server/WebServer.ts | 4 +- .../web-server/handlers/messageHandlers.ts | 10 +++-- .../web-server/managers/CallbackRegistry.ts | 4 +- src/main/web-server/types.ts | 7 +++- src/main/web-server/web-server-factory.ts | 8 +++- 8 files changed, 89 insertions(+), 15 deletions(-) diff --git a/src/__tests__/main/web-server/handlers/messageHandlers.test.ts b/src/__tests__/main/web-server/handlers/messageHandlers.test.ts index b419578599..80b64c5084 100644 --- a/src/__tests__/main/web-server/handlers/messageHandlers.test.ts +++ b/src/__tests__/main/web-server/handlers/messageHandlers.test.ts @@ -289,7 +289,7 @@ describe('WebSocketMessageHandler', () => { }); await vi.waitFor(() => { - expect(callbacks.selectSession).toHaveBeenCalledWith('session-2', undefined); + expect(callbacks.selectSession).toHaveBeenCalledWith('session-2', undefined, false); }); const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); @@ -305,7 +305,19 @@ describe('WebSocketMessageHandler', () => { }); await vi.waitFor(() => { - expect(callbacks.selectSession).toHaveBeenCalledWith('session-2', 'tab-5'); + expect(callbacks.selectSession).toHaveBeenCalledWith('session-2', 'tab-5', false); + }); + }); + + it('should forward session selection with focus', async () => { + handler.handleMessage(client, { + type: 'select_session', + sessionId: 'session-2', + focus: true, + }); + + await vi.waitFor(() => { + expect(callbacks.selectSession).toHaveBeenCalledWith('session-2', undefined, true); }); }); diff --git a/src/__tests__/main/web-server/managers/CallbackRegistry.test.ts b/src/__tests__/main/web-server/managers/CallbackRegistry.test.ts index 98f64f41f1..39907f9423 100644 --- a/src/__tests__/main/web-server/managers/CallbackRegistry.test.ts +++ b/src/__tests__/main/web-server/managers/CallbackRegistry.test.ts @@ -457,7 +457,7 @@ describe('CallbackRegistry', () => { await registry.selectSession('session-10'); - expect(callback).toHaveBeenCalledWith('session-10', undefined); + expect(callback).toHaveBeenCalledWith('session-10', undefined, undefined); }); it('passes sessionId and tabId arguments to the callback', async () => { @@ -466,7 +466,16 @@ describe('CallbackRegistry', () => { await registry.selectSession('session-10', 'tab-2'); - expect(callback).toHaveBeenCalledWith('session-10', 'tab-2'); + expect(callback).toHaveBeenCalledWith('session-10', 'tab-2', undefined); + }); + + it('passes focus argument to the callback', async () => { + const callback = vi.fn().mockResolvedValue(true); + registry.setSelectSessionCallback(callback); + + await registry.selectSession('session-10', undefined, true); + + expect(callback).toHaveBeenCalledWith('session-10', undefined, true); }); }); diff --git a/src/__tests__/main/web-server/web-server-factory.test.ts b/src/__tests__/main/web-server/web-server-factory.test.ts index 3fa364a53b..89f43b1078 100644 --- a/src/__tests__/main/web-server/web-server-factory.test.ts +++ b/src/__tests__/main/web-server/web-server-factory.test.ts @@ -151,6 +151,8 @@ describe('web-server/web-server-factory', () => { mockMainWindow = { isDestroyed: vi.fn().mockReturnValue(false), + show: vi.fn(), + focus: vi.fn(), webContents: mockWebContents as WebContents, }; @@ -511,6 +513,46 @@ describe('web-server/web-server-factory', () => { }); }); + describe('selectSessionCallback behavior', () => { + it('should send session selection to renderer without focusing by default', async () => { + const createWebServer = createWebServerFactory(deps); + const server = createWebServer(); + + const setSelectSessionCallback = server.setSelectSessionCallback as ReturnType; + const callback = setSelectSessionCallback.mock.calls[0][0]; + + const result = await callback('session-1', 'tab-1'); + + expect(result).toBe(true); + expect(mockMainWindow.show).not.toHaveBeenCalled(); + expect(mockMainWindow.focus).not.toHaveBeenCalled(); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'remote:selectSession', + 'session-1', + 'tab-1' + ); + }); + + it('should focus window before sending session selection when requested', async () => { + const createWebServer = createWebServerFactory(deps); + const server = createWebServer(); + + const setSelectSessionCallback = server.setSelectSessionCallback as ReturnType; + const callback = setSelectSessionCallback.mock.calls[0][0]; + + const result = await callback('session-1', undefined, true); + + expect(result).toBe(true); + expect(mockMainWindow.show).toHaveBeenCalled(); + expect(mockMainWindow.focus).toHaveBeenCalled(); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'remote:selectSession', + 'session-1', + undefined + ); + }); + }); + describe('openFileTabCallback behavior', () => { it('should return false when mainWindow is null', async () => { deps.getMainWindow = vi.fn().mockReturnValue(null); diff --git a/src/main/web-server/WebServer.ts b/src/main/web-server/WebServer.ts index a6063b90b2..fb79115028 100644 --- a/src/main/web-server/WebServer.ts +++ b/src/main/web-server/WebServer.ts @@ -496,8 +496,8 @@ export class WebServer { this.callbackRegistry.executeCommand(sessionId, command, inputMode), switchMode: async (sessionId: string, mode: 'ai' | 'terminal') => this.callbackRegistry.switchMode(sessionId, mode), - selectSession: async (sessionId: string, tabId?: string) => - this.callbackRegistry.selectSession(sessionId, tabId), + selectSession: async (sessionId: string, tabId?: string, focus?: boolean) => + this.callbackRegistry.selectSession(sessionId, tabId, focus), selectTab: async (sessionId: string, tabId: string) => this.callbackRegistry.selectTab(sessionId, tabId), newTab: async (sessionId: string) => this.callbackRegistry.newTab(sessionId), diff --git a/src/main/web-server/handlers/messageHandlers.ts b/src/main/web-server/handlers/messageHandlers.ts index acb271416a..7519832530 100644 --- a/src/main/web-server/handlers/messageHandlers.ts +++ b/src/main/web-server/handlers/messageHandlers.ts @@ -36,6 +36,7 @@ export interface WebClientMessage { tabId?: string; command?: string; filePath?: string; + focus?: boolean; mode?: 'ai' | 'terminal'; inputMode?: 'ai' | 'terminal'; newName?: string; @@ -81,7 +82,7 @@ export interface MessageHandlerCallbacks { inputMode?: 'ai' | 'terminal' ) => Promise; switchMode: (sessionId: string, mode: 'ai' | 'terminal') => Promise; - selectSession: (sessionId: string, tabId?: string) => Promise; + selectSession: (sessionId: string, tabId?: string, focus?: boolean) => Promise; selectTab: (sessionId: string, tabId: string) => Promise; newTab: (sessionId: string) => Promise<{ tabId: string } | null>; closeTab: (sessionId: string, tabId: string) => Promise; @@ -362,8 +363,9 @@ export class WebSocketMessageHandler { private handleSelectSession(client: WebClient, message: WebClientMessage): void { const sessionId = message.sessionId as string; const tabId = message.tabId as string | undefined; + const focus = message.focus === true; logger.info( - `[Web] Received select_session message: session=${sessionId}, tab=${tabId || 'none'}`, + `[Web] Received select_session message: session=${sessionId}, tab=${tabId || 'none'}, focus=${focus}`, LOG_CONTEXT ); @@ -380,11 +382,11 @@ export class WebSocketMessageHandler { // Forward to desktop's session selection logic (include tabId if provided) logger.info( - `[Web] Calling selectSessionCallback for session ${sessionId}${tabId ? `, tab ${tabId}` : ''}`, + `[Web] Calling selectSessionCallback for session ${sessionId}${tabId ? `, tab ${tabId}` : ''}${focus ? ', focus' : ''}`, LOG_CONTEXT ); this.callbacks - .selectSession(sessionId, tabId) + .selectSession(sessionId, tabId, focus) .then((success) => { if (success) { // Subscribe client to this session's output so they receive session_output messages diff --git a/src/main/web-server/managers/CallbackRegistry.ts b/src/main/web-server/managers/CallbackRegistry.ts index 1144af2ca6..ce318e5136 100644 --- a/src/main/web-server/managers/CallbackRegistry.ts +++ b/src/main/web-server/managers/CallbackRegistry.ts @@ -128,9 +128,9 @@ export class CallbackRegistry { return this.callbacks.switchMode(sessionId, mode); } - async selectSession(sessionId: string, tabId?: string): Promise { + async selectSession(sessionId: string, tabId?: string, focus?: boolean): Promise { if (!this.callbacks.selectSession) return false; - return this.callbacks.selectSession(sessionId, tabId); + return this.callbacks.selectSession(sessionId, tabId, focus); } async selectTab(sessionId: string, tabId: string): Promise { diff --git a/src/main/web-server/types.ts b/src/main/web-server/types.ts index 98f414c78f..f2820d436b 100644 --- a/src/main/web-server/types.ts +++ b/src/main/web-server/types.ts @@ -208,6 +208,7 @@ export interface WebClientMessage { tabId?: string; command?: string; filePath?: string; + focus?: boolean; mode?: 'ai' | 'terminal'; inputMode?: 'ai' | 'terminal'; newName?: string; @@ -264,7 +265,11 @@ export type SwitchModeCallback = (sessionId: string, mode: 'ai' | 'terminal') => * This forwards to the renderer which handles state updates and broadcasts. * Optional tabId to also switch to a specific tab within the session. */ -export type SelectSessionCallback = (sessionId: string, tabId?: string) => Promise; +export type SelectSessionCallback = ( + sessionId: string, + tabId?: string, + focus?: boolean +) => Promise; /** * Tab operation callbacks for multi-tab support. diff --git a/src/main/web-server/web-server-factory.ts b/src/main/web-server/web-server-factory.ts index ac769ff05a..33c041c380 100644 --- a/src/main/web-server/web-server-factory.ts +++ b/src/main/web-server/web-server-factory.ts @@ -350,9 +350,9 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) { // Set up callback for web server to select/switch to a session in the desktop // This forwards to the renderer which handles state updates and broadcasts // If tabId is provided, also switches to that tab within the session - server.setSelectSessionCallback(async (sessionId: string, tabId?: string) => { + server.setSelectSessionCallback(async (sessionId: string, tabId?: string, focus?: boolean) => { logger.info( - `[Web→Desktop] Session select callback invoked: session=${sessionId}, tab=${tabId || 'none'}`, + `[Web→Desktop] Session select callback invoked: session=${sessionId}, tab=${tabId || 'none'}, focus=${focus === true}`, 'WebServer' ); const mainWindow = getMainWindow(); @@ -367,6 +367,10 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) { logger.warn('webContents is not available for selectSession', 'WebServer'); return false; } + if (focus === true) { + mainWindow.show(); + mainWindow.focus(); + } mainWindow.webContents.send('remote:selectSession', sessionId, tabId); return true; }); From db400cb882205c9ed1e8a5ec9e2bb27aae9c1f34 Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 19:28:08 -0700 Subject: [PATCH 12/24] MAESTRO: Add CLI WebSocket client service --- src/cli/services/maestro-client.ts | 196 +++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 src/cli/services/maestro-client.ts diff --git a/src/cli/services/maestro-client.ts b/src/cli/services/maestro-client.ts new file mode 100644 index 0000000000..cc5c2d0973 --- /dev/null +++ b/src/cli/services/maestro-client.ts @@ -0,0 +1,196 @@ +import { randomUUID } from 'crypto'; +import WebSocket from 'ws'; +import { readCliServerInfo, isCliServerRunning } from '../../shared/cli-server-discovery'; + +const CONNECT_TIMEOUT_MS = 5000; +const COMMAND_TIMEOUT_MS = 10000; + +interface MaestroMessage { + type?: string; + requestId?: string; + success?: boolean; + error?: string; + message?: string; + [key: string]: unknown; +} + +interface PendingRequest { + responseType: string; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; + timeout: NodeJS.Timeout; +} + +export class MaestroClient { + private ws: WebSocket | null = null; + private pendingRequests = new Map>(); + + /** Connect to the running Maestro app. Throws if app not running. */ + async connect(): Promise { + const info = readCliServerInfo(); + if (!info) { + throw new Error('Maestro desktop app is not running'); + } + + if (!isCliServerRunning()) { + throw new Error('Maestro desktop app is not running'); + } + + await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://localhost:${info.port}/${info.token}/ws`); + let settled = false; + + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + ws.close(); + reject(new Error('Timed out connecting to Maestro desktop app')); + }, CONNECT_TIMEOUT_MS); + + const cleanup = (): void => { + clearTimeout(timeout); + ws.off('open', onOpen); + ws.off('error', onError); + }; + + const onOpen = (): void => { + if (settled) return; + settled = true; + cleanup(); + this.ws = ws; + ws.on('message', (data) => this.handleMessage(data)); + ws.on('close', () => this.rejectAllPending(new Error('Connection to Maestro closed'))); + ws.on('error', (error) => this.rejectAllPending(error)); + resolve(); + }; + + const onError = (error: Error): void => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + + ws.once('open', onOpen); + ws.once('error', onError); + }); + } + + /** Send a message and wait for a typed response. */ + async sendCommand( + message: object, + responseType: string, + timeoutMs = COMMAND_TIMEOUT_MS + ): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error('Not connected to Maestro desktop app'); + } + + const requestId = randomUUID(); + const payload = { ...message, requestId }; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pendingRequests.delete(requestId); + reject(new Error(`Timed out waiting for ${responseType}`)); + }, timeoutMs); + + this.pendingRequests.set(requestId, { + responseType, + resolve: resolve as (value: unknown) => void, + reject, + timeout, + }); + + this.ws!.send(JSON.stringify(payload), (error) => { + if (!error) return; + + const pending = this.pendingRequests.get(requestId); + if (pending) { + clearTimeout(pending.timeout); + this.pendingRequests.delete(requestId); + pending.reject(error); + } + }); + }); + } + + /** Disconnect gracefully. */ + disconnect(): void { + this.rejectAllPending(new Error('Disconnected from Maestro desktop app')); + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + private handleMessage(data: WebSocket.RawData): void { + let message: MaestroMessage; + try { + message = JSON.parse(data.toString()) as MaestroMessage; + } catch { + return; + } + + if (message.type === 'error') { + this.rejectMatchingOrAll(message); + return; + } + + for (const [requestId, pending] of this.pendingRequests) { + if (message.type !== pending.responseType) continue; + if (message.requestId && message.requestId !== requestId) continue; + + clearTimeout(pending.timeout); + this.pendingRequests.delete(requestId); + + if (message.success === false) { + pending.reject(new Error(this.getErrorMessage(message))); + } else { + pending.resolve(message); + } + return; + } + } + + private rejectMatchingOrAll(message: MaestroMessage): void { + const error = new Error(this.getErrorMessage(message)); + + if (message.requestId) { + const pending = this.pendingRequests.get(message.requestId); + if (pending) { + clearTimeout(pending.timeout); + this.pendingRequests.delete(message.requestId); + pending.reject(error); + } + return; + } + + this.rejectAllPending(error); + } + + private rejectAllPending(error: Error): void { + for (const [requestId, pending] of this.pendingRequests) { + clearTimeout(pending.timeout); + this.pendingRequests.delete(requestId); + pending.reject(error); + } + } + + private getErrorMessage(message: MaestroMessage): string { + return message.error || message.message || 'Maestro command failed'; + } +} + +/** Helper: create client, connect, run action, disconnect. */ +export async function withMaestroClient( + action: (client: MaestroClient) => Promise +): Promise { + const client = new MaestroClient(); + await client.connect(); + try { + return await action(client); + } finally { + client.disconnect(); + } +} From d83618464605cc2b5ad49b10caec75b4c7062958 Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 19:34:49 -0700 Subject: [PATCH 13/24] MAESTRO: add CLI open-file command --- src/__tests__/cli/commands/open-file.test.ts | 135 +++++++++++++++++++ src/cli/commands/open-file.ts | 69 ++++++++++ src/cli/index.ts | 7 + 3 files changed, 211 insertions(+) create mode 100644 src/__tests__/cli/commands/open-file.test.ts create mode 100644 src/cli/commands/open-file.ts diff --git a/src/__tests__/cli/commands/open-file.test.ts b/src/__tests__/cli/commands/open-file.test.ts new file mode 100644 index 0000000000..640a136c76 --- /dev/null +++ b/src/__tests__/cli/commands/open-file.test.ts @@ -0,0 +1,135 @@ +/** + * @file open-file.test.ts + * @description Tests for the open-file CLI command + */ + +import { describe, it, expect, vi, beforeEach, type MockInstance } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { SessionInfo } from '../../../shared/types'; + +vi.mock('fs', () => ({ + existsSync: vi.fn(), +})); + +vi.mock('../../../cli/services/maestro-client', () => ({ + withMaestroClient: vi.fn(), +})); + +vi.mock('../../../cli/services/storage', () => ({ + getSessionById: vi.fn(), + readSessions: vi.fn(), + readSettings: vi.fn(), +})); + +vi.mock('../../../cli/output/formatter', () => ({ + formatError: vi.fn((message: string) => `Error: ${message}`), +})); + +import { openFile } from '../../../cli/commands/open-file'; +import { withMaestroClient } from '../../../cli/services/maestro-client'; +import { getSessionById, readSessions, readSettings } from '../../../cli/services/storage'; + +describe('open-file command', () => { + let consoleSpy: MockInstance; + let consoleErrorSpy: MockInstance; + let processExitSpy: MockInstance; + + 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(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + vi.mocked(readSettings).mockReturnValue({}); + vi.mocked(readSessions).mockReturnValue([mockSession()]); + vi.mocked(getSessionById).mockReturnValue(undefined); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const client = { + sendCommand: vi.fn().mockResolvedValue({ + type: 'open_file_tab_result', + success: true, + }), + }; + return action(client as never); + }); + }); + + it('opens an existing file with an explicit session', async () => { + const filePath = path.resolve('README.md'); + + await openFile('README.md', { session: 'target-session' }); + + expect(withMaestroClient).toHaveBeenCalledTimes(1); + const action = vi.mocked(withMaestroClient).mock.calls[0][0]; + const sendCommand = vi.fn().mockResolvedValue({ type: 'open_file_tab_result', success: true }); + await action({ sendCommand } as never); + expect(sendCommand).toHaveBeenCalledWith( + { type: 'open_file_tab', sessionId: 'target-session', filePath }, + 'open_file_tab_result' + ); + expect(consoleSpy).toHaveBeenCalledWith('Opened README.md in Maestro'); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('uses active session from settings when no session is provided', async () => { + vi.mocked(readSettings).mockReturnValue({ activeSessionId: 'active-session' }); + vi.mocked(getSessionById).mockReturnValue(mockSession({ id: 'active-session' })); + + await openFile('/tmp/example.txt', {}); + + const action = vi.mocked(withMaestroClient).mock.calls[0][0]; + const sendCommand = vi.fn().mockResolvedValue({ type: 'open_file_tab_result', success: true }); + await action({ sendCommand } as never); + expect(sendCommand).toHaveBeenCalledWith( + { type: 'open_file_tab', sessionId: 'active-session', filePath: '/tmp/example.txt' }, + 'open_file_tab_result' + ); + }); + + it('falls back to the first stored session when there is no active session', async () => { + vi.mocked(readSessions).mockReturnValue([mockSession({ id: 'first-session' })]); + + await openFile('/tmp/example.txt', {}); + + const action = vi.mocked(withMaestroClient).mock.calls[0][0]; + const sendCommand = vi.fn().mockResolvedValue({ type: 'open_file_tab_result', success: true }); + await action({ sendCommand } as never); + expect(sendCommand).toHaveBeenCalledWith( + { type: 'open_file_tab', sessionId: 'first-session', filePath: '/tmp/example.txt' }, + 'open_file_tab_result' + ); + }); + + it('exits with an error for a missing file', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + await openFile('/tmp/missing.txt', { session: 'target-session' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error: Failed to open file: File not found: /tmp/missing.txt' + ); + expect(withMaestroClient).not.toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('exits with an error when Maestro is not reachable', async () => { + vi.mocked(withMaestroClient).mockRejectedValue(new Error('Maestro desktop app is not running')); + + await openFile('/tmp/example.txt', { session: 'target-session' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error: Failed to open file: Maestro desktop app is not running' + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/cli/commands/open-file.ts b/src/cli/commands/open-file.ts new file mode 100644 index 0000000000..573f217f4f --- /dev/null +++ b/src/cli/commands/open-file.ts @@ -0,0 +1,69 @@ +// Open file command +// Opens a file as a preview tab in the running Maestro desktop app. + +import * as fs from 'fs'; +import * as path from 'path'; +import { withMaestroClient } from '../services/maestro-client'; +import { getSessionById, readSessions, readSettings } from '../services/storage'; +import { formatError } from '../output/formatter'; + +interface OpenFileOptions { + session?: string; +} + +interface OpenFileResult { + type: 'open_file_tab_result'; + success?: boolean; + sessionId?: string; + filePath?: string; + error?: string; +} + +function resolveTargetSessionId(options: OpenFileOptions): string { + if (options.session) { + return options.session; + } + + const settings = readSettings(); + if (typeof settings.activeSessionId === 'string' && settings.activeSessionId) { + const activeSession = getSessionById(settings.activeSessionId); + if (activeSession) { + return activeSession.id; + } + } + + const firstSession = readSessions()[0]; + if (firstSession) { + return firstSession.id; + } + + throw new Error('No Maestro sessions found. Pass --session to target a specific session.'); +} + +export async function openFile(filePathArg: string, options: OpenFileOptions): Promise { + try { + const filePath = path.resolve(filePathArg); + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + const sessionId = resolveTargetSessionId(options); + + await withMaestroClient(async (client) => { + const result = await client.sendCommand( + { type: 'open_file_tab', sessionId, filePath }, + 'open_file_tab_result' + ); + + if (result.success === false) { + throw new Error(result.error || 'Failed to open file in Maestro'); + } + }); + + console.log(`Opened ${path.basename(filePath)} in Maestro`); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(formatError(`Failed to open file: ${message}`)); + process.exit(1); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 05d868e744..63a4feb7e3 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -12,6 +12,7 @@ import { showPlaybook } from './commands/show-playbook'; import { showAgent } from './commands/show-agent'; import { cleanPlaybooks } from './commands/clean-playbooks'; import { send } from './commands/send'; +import { openFile } from './commands/open-file'; import { listSessions } from './commands/list-sessions'; import { settingsList } from './commands/settings-list'; import { settingsGet } from './commands/settings-get'; @@ -120,6 +121,12 @@ program .option('-s, --session ', 'Resume an existing agent session (for multi-turn conversations)') .action(send); +program + .command('open-file ') + .description('Open a file as a preview tab in the Maestro desktop app') + .option('-s, --session ', 'Target session (defaults to active)') + .action(openFile); + // Settings commands const settings = program.command('settings').description('View and manage Maestro configuration'); From 447cf9d5e6ed7ef4217a280d8ce6cbf76b1ade1a Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 19:52:17 -0700 Subject: [PATCH 14/24] MAESTRO: add refresh-files CLI command --- src/__tests__/cli/commands/open-file.test.ts | 46 ++------ .../cli/commands/refresh-files.test.ts | 108 ++++++++++++++++++ .../services/maestro-client-session.test.ts | 58 ++++++++++ src/cli/commands/open-file.ts | 26 +---- src/cli/commands/refresh-files.ts | 39 +++++++ src/cli/index.ts | 7 ++ src/cli/services/maestro-client.ts | 26 +++++ 7 files changed, 247 insertions(+), 63 deletions(-) create mode 100644 src/__tests__/cli/commands/refresh-files.test.ts create mode 100644 src/__tests__/cli/services/maestro-client-session.test.ts create mode 100644 src/cli/commands/refresh-files.ts diff --git a/src/__tests__/cli/commands/open-file.test.ts b/src/__tests__/cli/commands/open-file.test.ts index 640a136c76..7d565be61a 100644 --- a/src/__tests__/cli/commands/open-file.test.ts +++ b/src/__tests__/cli/commands/open-file.test.ts @@ -6,53 +6,35 @@ import { describe, it, expect, vi, beforeEach, type MockInstance } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; -import type { SessionInfo } from '../../../shared/types'; vi.mock('fs', () => ({ existsSync: vi.fn(), })); vi.mock('../../../cli/services/maestro-client', () => ({ + resolveSessionId: vi.fn(), withMaestroClient: vi.fn(), })); -vi.mock('../../../cli/services/storage', () => ({ - getSessionById: vi.fn(), - readSessions: vi.fn(), - readSettings: vi.fn(), -})); - vi.mock('../../../cli/output/formatter', () => ({ formatError: vi.fn((message: string) => `Error: ${message}`), })); import { openFile } from '../../../cli/commands/open-file'; -import { withMaestroClient } from '../../../cli/services/maestro-client'; -import { getSessionById, readSessions, readSettings } from '../../../cli/services/storage'; +import { resolveSessionId, withMaestroClient } from '../../../cli/services/maestro-client'; describe('open-file command', () => { let consoleSpy: MockInstance; let consoleErrorSpy: MockInstance; let processExitSpy: MockInstance; - 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(); consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); - vi.mocked(readSettings).mockReturnValue({}); - vi.mocked(readSessions).mockReturnValue([mockSession()]); - vi.mocked(getSessionById).mockReturnValue(undefined); vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(resolveSessionId).mockReturnValue('target-session'); vi.mocked(withMaestroClient).mockImplementation(async (action) => { const client = { sendCommand: vi.fn().mockResolvedValue({ @@ -69,6 +51,7 @@ describe('open-file command', () => { await openFile('README.md', { session: 'target-session' }); + expect(resolveSessionId).toHaveBeenCalledWith({ session: 'target-session' }); expect(withMaestroClient).toHaveBeenCalledTimes(1); const action = vi.mocked(withMaestroClient).mock.calls[0][0]; const sendCommand = vi.fn().mockResolvedValue({ type: 'open_file_tab_result', success: true }); @@ -81,23 +64,8 @@ describe('open-file command', () => { expect(processExitSpy).not.toHaveBeenCalled(); }); - it('uses active session from settings when no session is provided', async () => { - vi.mocked(readSettings).mockReturnValue({ activeSessionId: 'active-session' }); - vi.mocked(getSessionById).mockReturnValue(mockSession({ id: 'active-session' })); - - await openFile('/tmp/example.txt', {}); - - const action = vi.mocked(withMaestroClient).mock.calls[0][0]; - const sendCommand = vi.fn().mockResolvedValue({ type: 'open_file_tab_result', success: true }); - await action({ sendCommand } as never); - expect(sendCommand).toHaveBeenCalledWith( - { type: 'open_file_tab', sessionId: 'active-session', filePath: '/tmp/example.txt' }, - 'open_file_tab_result' - ); - }); - - it('falls back to the first stored session when there is no active session', async () => { - vi.mocked(readSessions).mockReturnValue([mockSession({ id: 'first-session' })]); + it('opens an existing file with the resolved default session', async () => { + vi.mocked(resolveSessionId).mockReturnValue('resolved-session'); await openFile('/tmp/example.txt', {}); @@ -105,7 +73,7 @@ describe('open-file command', () => { const sendCommand = vi.fn().mockResolvedValue({ type: 'open_file_tab_result', success: true }); await action({ sendCommand } as never); expect(sendCommand).toHaveBeenCalledWith( - { type: 'open_file_tab', sessionId: 'first-session', filePath: '/tmp/example.txt' }, + { type: 'open_file_tab', sessionId: 'resolved-session', filePath: '/tmp/example.txt' }, 'open_file_tab_result' ); }); 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..0353ee4a21 --- /dev/null +++ b/src/__tests__/cli/commands/refresh-files.test.ts @@ -0,0 +1,108 @@ +/** + * @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', () => ({ + resolveSessionId: 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 { resolveSessionId, 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(resolveSessionId).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 session', async () => { + await refreshFiles({ session: 'target-session' }); + + expect(resolveSessionId).toHaveBeenCalledWith({ session: 'target-session' }); + 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 session', async () => { + vi.mocked(resolveSessionId).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({ session: 'target-session' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error: Failed to refresh file tree: 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({ session: 'target-session' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error: Failed to refresh file tree: Session not found' + ); + 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/cli/commands/open-file.ts b/src/cli/commands/open-file.ts index 573f217f4f..d985b265b0 100644 --- a/src/cli/commands/open-file.ts +++ b/src/cli/commands/open-file.ts @@ -3,8 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { withMaestroClient } from '../services/maestro-client'; -import { getSessionById, readSessions, readSettings } from '../services/storage'; +import { resolveSessionId, withMaestroClient } from '../services/maestro-client'; import { formatError } from '../output/formatter'; interface OpenFileOptions { @@ -19,27 +18,6 @@ interface OpenFileResult { error?: string; } -function resolveTargetSessionId(options: OpenFileOptions): string { - if (options.session) { - return options.session; - } - - const settings = readSettings(); - if (typeof settings.activeSessionId === 'string' && settings.activeSessionId) { - const activeSession = getSessionById(settings.activeSessionId); - if (activeSession) { - return activeSession.id; - } - } - - const firstSession = readSessions()[0]; - if (firstSession) { - return firstSession.id; - } - - throw new Error('No Maestro sessions found. Pass --session to target a specific session.'); -} - export async function openFile(filePathArg: string, options: OpenFileOptions): Promise { try { const filePath = path.resolve(filePathArg); @@ -47,7 +25,7 @@ export async function openFile(filePathArg: string, options: OpenFileOptions): P throw new Error(`File not found: ${filePath}`); } - const sessionId = resolveTargetSessionId(options); + const sessionId = resolveSessionId(options); await withMaestroClient(async (client) => { const result = await client.sendCommand( diff --git a/src/cli/commands/refresh-files.ts b/src/cli/commands/refresh-files.ts new file mode 100644 index 0000000000..5819ab0763 --- /dev/null +++ b/src/cli/commands/refresh-files.ts @@ -0,0 +1,39 @@ +// Refresh files command +// Refreshes the file tree in the running Maestro desktop app. + +import { resolveSessionId, withMaestroClient } from '../services/maestro-client'; +import { formatError } from '../output/formatter'; + +interface RefreshFilesOptions { + session?: string; +} + +interface RefreshFilesResult { + type: 'refresh_file_tree_result'; + success?: boolean; + sessionId?: string; + error?: string; +} + +export async function refreshFiles(options: RefreshFilesOptions): Promise { + try { + const sessionId = resolveSessionId(options); + + await withMaestroClient(async (client) => { + const result = await client.sendCommand( + { type: 'refresh_file_tree', sessionId }, + 'refresh_file_tree_result' + ); + + if (result.success === false) { + throw new Error(result.error || 'Failed to refresh file tree'); + } + }); + + console.log('File tree refreshed'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(formatError(`Failed to refresh file tree: ${message}`)); + process.exit(1); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 63a4feb7e3..7221dab6a3 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -13,6 +13,7 @@ import { showAgent } from './commands/show-agent'; import { cleanPlaybooks } from './commands/clean-playbooks'; import { send } from './commands/send'; import { openFile } from './commands/open-file'; +import { refreshFiles } from './commands/refresh-files'; import { listSessions } from './commands/list-sessions'; import { settingsList } from './commands/settings-list'; import { settingsGet } from './commands/settings-get'; @@ -127,6 +128,12 @@ program .option('-s, --session ', 'Target session (defaults to active)') .action(openFile); +program + .command('refresh-files') + .description('Refresh the file tree in the Maestro desktop app') + .option('-s, --session ', 'Target session (defaults to active)') + .action(refreshFiles); + // Settings commands const settings = program.command('settings').description('View and manage Maestro configuration'); diff --git a/src/cli/services/maestro-client.ts b/src/cli/services/maestro-client.ts index cc5c2d0973..8938cbade6 100644 --- a/src/cli/services/maestro-client.ts +++ b/src/cli/services/maestro-client.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'crypto'; import WebSocket from 'ws'; import { readCliServerInfo, isCliServerRunning } from '../../shared/cli-server-discovery'; +import { getSessionById, readSessions, readSettings } from './storage'; const CONNECT_TIMEOUT_MS = 5000; const COMMAND_TIMEOUT_MS = 10000; @@ -21,6 +22,10 @@ interface PendingRequest { timeout: NodeJS.Timeout; } +export interface SessionResolutionOptions { + session?: string; +} + export class MaestroClient { private ws: WebSocket | null = null; private pendingRequests = new Map>(); @@ -194,3 +199,24 @@ export async function withMaestroClient( client.disconnect(); } } + +export function resolveSessionId(options: SessionResolutionOptions = {}): string { + if (options.session) { + return options.session; + } + + const settings = readSettings(); + if (typeof settings.activeSessionId === 'string' && settings.activeSessionId) { + const activeSession = getSessionById(settings.activeSessionId); + if (activeSession) { + return activeSession.id; + } + } + + const firstSession = readSessions()[0]; + if (firstSession) { + return firstSession.id; + } + + throw new Error('No Maestro sessions found. Pass --session to target a specific session.'); +} From 15faf59bf34a2582edb41f1280c664acff0b2a34 Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 19:57:02 -0700 Subject: [PATCH 15/24] MAESTRO: add refresh-auto-run CLI command --- .../cli/commands/refresh-auto-run.test.ts | 108 ++++++++++++++++++ src/cli/commands/refresh-auto-run.ts | 39 +++++++ src/cli/index.ts | 7 ++ 3 files changed, 154 insertions(+) create mode 100644 src/__tests__/cli/commands/refresh-auto-run.test.ts create mode 100644 src/cli/commands/refresh-auto-run.ts 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..8ee446b081 --- /dev/null +++ b/src/__tests__/cli/commands/refresh-auto-run.test.ts @@ -0,0 +1,108 @@ +/** + * @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', () => ({ + resolveSessionId: 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 { resolveSessionId, 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(resolveSessionId).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 session', async () => { + await refreshAutoRun({ session: 'target-session' }); + + expect(resolveSessionId).toHaveBeenCalledWith({ session: 'target-session' }); + 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 session', async () => { + vi.mocked(resolveSessionId).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({ session: 'target-session' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error: Failed to refresh Auto Run documents: 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({ session: 'target-session' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error: Failed to refresh Auto Run documents: Session not found' + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/cli/commands/refresh-auto-run.ts b/src/cli/commands/refresh-auto-run.ts new file mode 100644 index 0000000000..ec2116a30e --- /dev/null +++ b/src/cli/commands/refresh-auto-run.ts @@ -0,0 +1,39 @@ +// Refresh Auto Run command +// Refreshes Auto Run documents in the running Maestro desktop app. + +import { resolveSessionId, withMaestroClient } from '../services/maestro-client'; +import { formatError } from '../output/formatter'; + +interface RefreshAutoRunOptions { + session?: string; +} + +interface RefreshAutoRunResult { + type: 'refresh_auto_run_docs_result'; + success?: boolean; + sessionId?: string; + error?: string; +} + +export async function refreshAutoRun(options: RefreshAutoRunOptions): Promise { + try { + const sessionId = resolveSessionId(options); + + await withMaestroClient(async (client) => { + const result = await client.sendCommand( + { type: 'refresh_auto_run_docs', sessionId }, + 'refresh_auto_run_docs_result' + ); + + if (result.success === false) { + throw new Error(result.error || 'Failed to refresh Auto Run documents'); + } + }); + + console.log('Auto Run documents refreshed'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(formatError(`Failed to refresh Auto Run documents: ${message}`)); + process.exit(1); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 7221dab6a3..98ae6359c7 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -14,6 +14,7 @@ import { cleanPlaybooks } from './commands/clean-playbooks'; import { send } from './commands/send'; import { openFile } from './commands/open-file'; import { refreshFiles } from './commands/refresh-files'; +import { refreshAutoRun } from './commands/refresh-auto-run'; import { listSessions } from './commands/list-sessions'; import { settingsList } from './commands/settings-list'; import { settingsGet } from './commands/settings-get'; @@ -134,6 +135,12 @@ program .option('-s, --session ', 'Target session (defaults to active)') .action(refreshFiles); +program + .command('refresh-auto-run') + .description('Refresh Auto Run documents in the Maestro desktop app') + .option('-s, --session ', 'Target session (defaults to active)') + .action(refreshAutoRun); + // Settings commands const settings = program.command('settings').description('View and manage Maestro configuration'); From 4b546a04dccf147a50cf578cf25799babd62b7bd Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 20:02:21 -0700 Subject: [PATCH 16/24] MAESTRO: Add send tab focus flag --- src/__tests__/cli/commands/send.test.ts | 66 +++++++++++++++++++++++++ src/cli/commands/send.ts | 21 ++++++++ src/cli/index.ts | 1 + 3 files changed, 88 insertions(+) diff --git a/src/__tests__/cli/commands/send.test.ts b/src/__tests__/cli/commands/send.test.ts index 3bd3a852ad..131e39d520 100644 --- a/src/__tests__/cli/commands/send.test.ts +++ b/src/__tests__/cli/commands/send.test.ts @@ -25,6 +25,11 @@ vi.mock('../../../cli/services/storage', () => ({ getSessionById: vi.fn(), })); +// Mock Maestro desktop client +vi.mock('../../../cli/services/maestro-client', () => ({ + withMaestroClient: vi.fn(), +})); + // Mock usage-aggregator vi.mock('../../../main/parsers/usage-aggregator', () => ({ estimateContextUsage: vi.fn(), @@ -46,10 +51,12 @@ vi.mock('../../../main/agents/definitions', () => ({ import { send } from '../../../cli/commands/send'; import { spawnAgent, detectAgent } from '../../../cli/services/agent-spawner'; import { resolveAgentId, getSessionById } from '../../../cli/services/storage'; +import { withMaestroClient } from '../../../cli/services/maestro-client'; import { estimateContextUsage } from '../../../main/parsers/usage-aggregator'; describe('send command', () => { let consoleSpy: MockInstance; + let consoleErrorSpy: MockInstance; let processExitSpy: MockInstance; const mockAgent = (overrides: Partial = {}): SessionInfo => ({ @@ -64,6 +71,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); }); @@ -275,4 +283,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: Sent message, but could not focus Maestro tab: Maestro desktop app is not running' + ); + expect(processExitSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/cli/commands/send.ts b/src/cli/commands/send.ts index 151c0f8923..34cf189d16 100644 --- a/src/cli/commands/send.ts +++ b/src/cli/commands/send.ts @@ -3,12 +3,14 @@ import { spawnAgent, detectAgent, type AgentResult } from '../services/agent-spawner'; import { resolveAgentId, getSessionById } from '../services/storage'; +import { withMaestroClient } from '../services/maestro-client'; import { estimateContextUsage } from '../../main/parsers/usage-aggregator'; import { getAgentDefinition } from '../../main/agents/definitions'; import type { ToolType } from '../../shared/types'; interface SendOptions { session?: string; + tab?: boolean; } interface SendResponse { @@ -33,6 +35,11 @@ function emitErrorJson(error: string, code: string): void { console.log(JSON.stringify({ success: false, error, code }, null, 2)); } +function emitTabWarning(error: unknown): void { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(`Warning: Sent message, but could not focus Maestro tab: ${message}`); +} + function buildResponse( agentId: string, agentName: string, @@ -114,5 +121,19 @@ export async function send( if (!result.success) { process.exit(1); + return; + } + + if (options.tab) { + try { + await withMaestroClient(async (client) => { + await client.sendCommand( + { type: 'select_session', sessionId: agentId, focus: true }, + 'select_session_result' + ); + }); + } catch (error) { + emitTabWarning(error); + } } } diff --git a/src/cli/index.ts b/src/cli/index.ts index 98ae6359c7..be0015b453 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -121,6 +121,7 @@ program .command('send ') .description('Send a message to an agent and get a JSON response') .option('-s, --session ', 'Resume an existing agent session (for multi-turn conversations)') + .option('-t, --tab', 'Open/focus the session tab in Maestro desktop') .action(send); program From ad9e6d30008a5bf989bca1a91f54f401d3373123 Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 20:07:34 -0700 Subject: [PATCH 17/24] MAESTRO: Add CLI status command --- src/__tests__/cli/commands/status.test.ts | 124 ++++++++++++++++++++++ src/cli/commands/status.ts | 41 +++++++ src/cli/index.ts | 6 ++ 3 files changed, 171 insertions(+) create mode 100644 src/__tests__/cli/commands/status.test.ts create mode 100644 src/cli/commands/status.ts diff --git a/src/__tests__/cli/commands/status.test.ts b/src/__tests__/cli/commands/status.test.ts new file mode 100644 index 0000000000..49bb274844 --- /dev/null +++ b/src/__tests__/cli/commands/status.test.ts @@ -0,0 +1,124 @@ +/** + * @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).not.toHaveBeenCalled(); + }); + + 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).not.toHaveBeenCalled(); + }); + + it('pings Maestro and prints the port with session 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 sessions'); + 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 sessions'); + }); + + 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: Failed to reach Maestro desktop app: Connection refused' + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts new file mode 100644 index 0000000000..245ea1b8dc --- /dev/null +++ b/src/cli/commands/status.ts @@ -0,0 +1,41 @@ +// Status command +// Checks whether the Maestro desktop app is reachable via CLI IPC. + +import { readCliServerInfo, isCliServerRunning } from '../../shared/cli-server-discovery'; +import { formatError } from '../output/formatter'; +import { withMaestroClient } from '../services/maestro-client'; + +interface SessionsListResult { + type: 'sessions_list'; + sessions?: unknown[]; +} + +export async function status(): Promise { + const info = readCliServerInfo(); + if (!info) { + console.log('Maestro desktop app is not running'); + return; + } + + if (!isCliServerRunning()) { + console.log('Maestro discovery file is stale (app may have crashed)'); + return; + } + + try { + const sessionCount = await withMaestroClient(async (client) => { + await client.sendCommand({ type: 'ping' }, 'pong'); + const result = await client.sendCommand( + { type: 'get_sessions' }, + 'sessions_list' + ); + return Array.isArray(result.sessions) ? result.sessions.length : 0; + }); + + console.log(`Maestro is running on port ${info.port} with ${sessionCount} sessions`); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(formatError(`Failed to reach Maestro desktop app: ${message}`)); + process.exit(1); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index be0015b453..8a219a576c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -15,6 +15,7 @@ import { send } from './commands/send'; import { openFile } from './commands/open-file'; import { refreshFiles } from './commands/refresh-files'; import { refreshAutoRun } from './commands/refresh-auto-run'; +import { status } from './commands/status'; import { listSessions } from './commands/list-sessions'; import { settingsList } from './commands/settings-list'; import { settingsGet } from './commands/settings-get'; @@ -142,6 +143,11 @@ program .option('-s, --session ', 'Target session (defaults to active)') .action(refreshAutoRun); +program + .command('status') + .description('Check whether the Maestro desktop app is running and reachable') + .action(status); + // Settings commands const settings = program.command('settings').description('View and manage Maestro configuration'); From 32cee44c687766843b44e2cfe43c70302a7490ae Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 20:12:55 -0700 Subject: [PATCH 18/24] MAESTRO: Add CLI Maestro client tests --- .../cli/services/maestro-client.test.ts | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 src/__tests__/cli/services/maestro-client.test.ts diff --git a/src/__tests__/cli/services/maestro-client.test.ts b/src/__tests__/cli/services/maestro-client.test.ts new file mode 100644 index 0000000000..817098089e --- /dev/null +++ b/src/__tests__/cli/services/maestro-client.test.ts @@ -0,0 +1,186 @@ +/** + * @file maestro-client.test.ts + * @description Tests for the CLI Maestro WebSocket client + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const wsMock = vi.hoisted(() => { + type Listener = (...args: unknown[]) => void; + + class MockWebSocket { + static OPEN = 1; + + url: string; + readyState = MockWebSocket.OPEN; + sent: string[] = []; + listeners = new Map(); + + constructor(url: string) { + this.url = url; + wsMock.instances.push(this); + } + + on(event: string, listener: Listener): this { + this.listeners.set(event, [...(this.listeners.get(event) || []), listener]); + return this; + } + + once(event: string, listener: Listener): this { + const onceListener: Listener = (...args) => { + this.off(event, onceListener); + listener(...args); + }; + return this.on(event, onceListener); + } + + off(event: string, listener: Listener): this { + this.listeners.set( + event, + (this.listeners.get(event) || []).filter((current) => current !== listener) + ); + return this; + } + + send(data: string, callback?: (error?: Error) => void): void { + this.sent.push(data); + callback?.(); + } + + close(): void { + this.readyState = 3; + this.emit('close'); + } + + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners.get(event) || []) { + listener(...args); + } + } + } + + return { + MockWebSocket, + instances: [] as MockWebSocket[], + }; +}); + +vi.mock('ws', () => ({ + default: wsMock.MockWebSocket, +})); + +vi.mock('../../../shared/cli-server-discovery', () => ({ + readCliServerInfo: vi.fn(), + isCliServerRunning: vi.fn(), +})); + +import { MaestroClient, withMaestroClient } from '../../../cli/services/maestro-client'; +import { readCliServerInfo, isCliServerRunning } from '../../../shared/cli-server-discovery'; + +describe('MaestroClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + wsMock.instances.length = 0; + vi.mocked(readCliServerInfo).mockReturnValue({ + port: 47321, + token: 'test-token', + pid: 1234, + startedAt: 1710000000000, + }); + vi.mocked(isCliServerRunning).mockReturnValue(true); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('connect() throws when no discovery file exists', async () => { + vi.mocked(readCliServerInfo).mockReturnValue(null); + const client = new MaestroClient(); + + await expect(client.connect()).rejects.toThrow('Maestro desktop app is not running'); + expect(wsMock.instances).toHaveLength(0); + }); + + it('connect() throws when the discovery PID is stale', async () => { + vi.mocked(isCliServerRunning).mockReturnValue(false); + const client = new MaestroClient(); + + await expect(client.connect()).rejects.toThrow('Maestro desktop app is not running'); + expect(wsMock.instances).toHaveLength(0); + }); + + it('connects to the discovered WebSocket endpoint', async () => { + const client = new MaestroClient(); + const connectPromise = client.connect(); + + expect(wsMock.instances).toHaveLength(1); + expect(wsMock.instances[0].url).toBe('ws://localhost:47321/test-token/ws'); + + wsMock.instances[0].emit('open'); + await expect(connectPromise).resolves.toBeUndefined(); + }); + + it('sendCommand() resolves on a matching response type', async () => { + const client = new MaestroClient(); + const connectPromise = client.connect(); + const ws = wsMock.instances[0]; + ws.emit('open'); + await connectPromise; + + const responsePromise = client.sendCommand<{ type: string; ok: boolean }>( + { type: 'ping' }, + 'pong' + ); + const payload = JSON.parse(ws.sent[0]) as { requestId: string; type: string }; + + expect(payload.type).toBe('ping'); + expect(payload.requestId).toBeTruthy(); + + ws.emit( + 'message', + Buffer.from(JSON.stringify({ type: 'pong', requestId: payload.requestId, ok: true })) + ); + + await expect(responsePromise).resolves.toMatchObject({ type: 'pong', ok: true }); + }); + + it('sendCommand() rejects on timeout', async () => { + vi.useFakeTimers(); + const client = new MaestroClient(); + const connectPromise = client.connect(); + wsMock.instances[0].emit('open'); + await connectPromise; + + const responsePromise = client.sendCommand({ type: 'ping' }, 'pong', 50); + const assertion = expect(responsePromise).rejects.toThrow('Timed out waiting for pong'); + await vi.advanceTimersByTimeAsync(50); + + await assertion; + }); + + it('withMaestroClient() connects, runs the action, and disconnects', async () => { + const resultPromise = withMaestroClient(async (client) => { + const response = await client.sendCommand<{ type: string; success: boolean }>( + { type: 'ping' }, + 'pong' + ); + return response.success; + }); + + const ws = wsMock.instances[0]; + ws.emit('open'); + await vi.waitFor(() => { + expect(ws.sent).toHaveLength(1); + }); + const payload = JSON.parse(ws.sent[0]) as { requestId: string }; + ws.emit( + 'message', + Buffer.from(JSON.stringify({ type: 'pong', requestId: payload.requestId, success: true })) + ); + + await expect(resultPromise).resolves.toBe(true); + expect(ws.readyState).toBe(3); + }); +}); From cded1aceed34235a3cc56ac56da01325e5e79e5f Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 20:18:24 -0700 Subject: [PATCH 19/24] MAESTRO: Add configure auto-run IPC plumbing --- .../web-server/web-server-factory.test.ts | 51 ++++++++++++++ src/main/preload/process.ts | 24 +++++++ src/main/web-server/WebServer.ts | 7 ++ .../web-server/handlers/messageHandlers.ts | 68 +++++++++++++++++++ .../web-server/managers/CallbackRegistry.ts | 19 ++++++ src/main/web-server/types.ts | 33 +++++++++ src/main/web-server/web-server-factory.ts | 19 ++++++ src/renderer/global.d.ts | 12 ++++ 8 files changed, 233 insertions(+) diff --git a/src/__tests__/main/web-server/web-server-factory.test.ts b/src/__tests__/main/web-server/web-server-factory.test.ts index 89f43b1078..cfa7386b34 100644 --- a/src/__tests__/main/web-server/web-server-factory.test.ts +++ b/src/__tests__/main/web-server/web-server-factory.test.ts @@ -41,6 +41,7 @@ vi.mock('../../../main/web-server/WebServer', () => { setOpenFileTabCallback = vi.fn(); setRefreshFileTreeCallback = vi.fn(); setRefreshAutoRunDocsCallback = vi.fn(); + setConfigureAutoRunCallback = vi.fn(); constructor(port: number, securityToken?: string) { this.port = port; @@ -370,6 +371,10 @@ describe('web-server/web-server-factory', () => { it('should register refreshAutoRunDocsCallback', () => { expect(server.setRefreshAutoRunDocsCallback).toHaveBeenCalled(); }); + + it('should register configureAutoRunCallback', () => { + expect(server.setConfigureAutoRunCallback).toHaveBeenCalled(); + }); }); describe('getSessionsCallback behavior', () => { @@ -649,6 +654,52 @@ describe('web-server/web-server-factory', () => { }); }); + describe('configureAutoRunCallback behavior', () => { + it('should return an error when mainWindow is null', async () => { + deps.getMainWindow = vi.fn().mockReturnValue(null); + const createWebServer = createWebServerFactory(deps); + const server = createWebServer(); + + const setConfigureAutoRunCallback = server.setConfigureAutoRunCallback as ReturnType< + typeof vi.fn + >; + const callback = setConfigureAutoRunCallback.mock.calls[0][0]; + + const result = await callback('session-1', { + documents: [{ filename: 'task.md' }], + }); + + expect(result).toEqual({ success: false, error: 'Main window is not available' }); + }); + + it('should send Auto Run config to renderer', async () => { + const createWebServer = createWebServerFactory(deps); + const server = createWebServer(); + + const setConfigureAutoRunCallback = server.setConfigureAutoRunCallback as ReturnType< + typeof vi.fn + >; + const callback = setConfigureAutoRunCallback.mock.calls[0][0]; + const config = { + documents: [{ filename: 'task.md', resetOnCompletion: true }], + prompt: 'Run these tasks', + loopEnabled: true, + maxLoops: 3, + saveAsPlaybook: 'My Playbook', + launch: true, + }; + + const result = await callback('session-1', config); + + expect(result).toEqual({ success: true }); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'remote:configureAutoRun', + 'session-1', + config + ); + }); + }); + describe('getThemeCallback behavior', () => { it('should return theme from getThemeById', () => { const createWebServer = createWebServerFactory(deps); diff --git a/src/main/preload/process.ts b/src/main/preload/process.ts index 23836d7ad1..5512c7d21c 100644 --- a/src/main/preload/process.ts +++ b/src/main/preload/process.ts @@ -129,6 +129,18 @@ export interface SshRemoteInfo { host: string; } +/** + * Auto Run configuration from remote CLI/web IPC. + */ +export interface ConfigureAutoRunConfig { + documents: Array<{ filename: string; resetOnCompletion?: boolean }>; + prompt?: string; + loopEnabled?: boolean; + maxLoops?: number; + saveAsPlaybook?: string; + launch?: boolean; +} + /** * Creates the process API object for preload exposure */ @@ -446,6 +458,18 @@ export function createProcessApi() { return () => ipcRenderer.removeListener('remote:refreshAutoRunDocs', handler); }, + /** + * Subscribe to remote Auto Run configuration from web interface + */ + onRemoteConfigureAutoRun: ( + callback: (sessionId: string, config: ConfigureAutoRunConfig) => void + ): (() => void) => { + const handler = (_: unknown, sessionId: string, config: ConfigureAutoRunConfig) => + callback(sessionId, config); + ipcRenderer.on('remote:configureAutoRun', handler); + return () => ipcRenderer.removeListener('remote:configureAutoRun', handler); + }, + /** * Subscribe to stderr from runCommand (separate stream) */ diff --git a/src/main/web-server/WebServer.ts b/src/main/web-server/WebServer.ts index fb79115028..2ba31a12dd 100644 --- a/src/main/web-server/WebServer.ts +++ b/src/main/web-server/WebServer.ts @@ -66,6 +66,7 @@ import type { OpenFileTabCallback, RefreshFileTreeCallback, RefreshAutoRunDocsCallback, + ConfigureAutoRunCallback, GetThemeCallback, GetBionifyReadingModeCallback, GetCustomCommandsCallback, @@ -359,6 +360,10 @@ export class WebServer { this.callbackRegistry.setRefreshAutoRunDocsCallback(callback); } + setConfigureAutoRunCallback(callback: ConfigureAutoRunCallback): void { + this.callbackRegistry.setConfigureAutoRunCallback(callback); + } + setGetHistoryCallback(callback: GetHistoryCallback): void { this.callbackRegistry.setGetHistoryCallback(callback); } @@ -516,6 +521,8 @@ export class WebServer { this.callbackRegistry.refreshFileTree(sessionId), refreshAutoRunDocs: async (sessionId: string) => this.callbackRegistry.refreshAutoRunDocs(sessionId), + configureAutoRun: async (sessionId, config) => + this.callbackRegistry.configureAutoRun(sessionId, config), getSessions: () => this.callbackRegistry.getSessions(), getLiveSessionInfo: (sessionId: string) => this.liveSessionManager.getLiveSessionInfo(sessionId), diff --git a/src/main/web-server/handlers/messageHandlers.ts b/src/main/web-server/handlers/messageHandlers.ts index 7519832530..20733bc3b6 100644 --- a/src/main/web-server/handlers/messageHandlers.ts +++ b/src/main/web-server/handlers/messageHandlers.ts @@ -19,10 +19,12 @@ * - open_file_tab: Open a file preview tab in desktop * - refresh_file_tree: Refresh the file tree in desktop * - refresh_auto_run_docs: Refresh Auto Run documents in desktop + * - configure_auto_run: Configure or launch Auto Run in desktop */ import { WebSocket } from 'ws'; import { logger } from '../../utils/logger'; +import type { ConfigureAutoRunConfig, ConfigureAutoRunDocument } from '../types'; // Logger context for all message handler logs const LOG_CONTEXT = 'WebServer'; @@ -93,6 +95,10 @@ export interface MessageHandlerCallbacks { openFileTab: (sessionId: string, filePath: string) => Promise; refreshFileTree: (sessionId: string) => Promise; refreshAutoRunDocs: (sessionId: string) => Promise; + configureAutoRun: ( + sessionId: string, + config: ConfigureAutoRunConfig + ) => Promise<{ success: boolean; playbookId?: string; error?: string }>; getSessions: () => Array<{ id: string; name: string; @@ -214,6 +220,10 @@ export class WebSocketMessageHandler { this.handleRefreshAutoRunDocs(client, message); break; + case 'configure_auto_run': + this.handleConfigureAutoRun(client, message); + break; + default: this.handleUnknown(client, message); } @@ -741,6 +751,64 @@ export class WebSocketMessageHandler { }); } + /** + * Handle configure_auto_run message - configure or launch Auto Run in desktop + */ + private handleConfigureAutoRun(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + const documents = message.documents as ConfigureAutoRunDocument[] | undefined; + logger.info(`[Web] Received configure_auto_run message: session=${sessionId}`, LOG_CONTEXT); + + if (!sessionId) { + this.send(client, { + type: 'configure_auto_run_result', + success: false, + error: 'Missing sessionId', + }); + return; + } + + if (!Array.isArray(documents) || documents.length === 0) { + this.send(client, { + type: 'configure_auto_run_result', + success: false, + error: 'Missing documents', + }); + return; + } + + if (!this.callbacks.configureAutoRun) { + this.send(client, { + type: 'configure_auto_run_result', + success: false, + error: 'Auto Run configuration not configured', + }); + return; + } + + const config: ConfigureAutoRunConfig = { + documents, + prompt: message.prompt as string | undefined, + loopEnabled: message.loopEnabled as boolean | undefined, + maxLoops: message.maxLoops as number | undefined, + saveAsPlaybook: message.saveAsPlaybook as string | undefined, + launch: message.launch as boolean | undefined, + }; + + this.callbacks + .configureAutoRun(sessionId, config) + .then((result) => { + this.send(client, { type: 'configure_auto_run_result', ...result }); + }) + .catch((error) => { + this.send(client, { + type: 'configure_auto_run_result', + success: false, + error: `Failed to configure Auto Run: ${error.message}`, + }); + }); + } + /** * Handle unknown message types - echo back for debugging */ diff --git a/src/main/web-server/managers/CallbackRegistry.ts b/src/main/web-server/managers/CallbackRegistry.ts index ce318e5136..04bd42ad0d 100644 --- a/src/main/web-server/managers/CallbackRegistry.ts +++ b/src/main/web-server/managers/CallbackRegistry.ts @@ -24,6 +24,9 @@ import type { OpenFileTabCallback, RefreshFileTreeCallback, RefreshAutoRunDocsCallback, + ConfigureAutoRunCallback, + ConfigureAutoRunConfig, + ConfigureAutoRunResult, GetThemeCallback, GetBionifyReadingModeCallback, GetCustomCommandsCallback, @@ -56,6 +59,7 @@ export interface WebServerCallbacks { openFileTab: OpenFileTabCallback | null; refreshFileTree: RefreshFileTreeCallback | null; refreshAutoRunDocs: RefreshAutoRunDocsCallback | null; + configureAutoRun: ConfigureAutoRunCallback | null; getHistory: GetHistoryCallback | null; } @@ -81,6 +85,7 @@ export class CallbackRegistry { openFileTab: null, refreshFileTree: null, refreshAutoRunDocs: null, + configureAutoRun: null, getHistory: null, }; @@ -183,6 +188,16 @@ export class CallbackRegistry { return this.callbacks.refreshAutoRunDocs(sessionId); } + async configureAutoRun( + sessionId: string, + config: ConfigureAutoRunConfig + ): Promise { + if (!this.callbacks.configureAutoRun) { + return { success: false, error: 'Auto Run configuration not configured' }; + } + return this.callbacks.configureAutoRun(sessionId, config); + } + getHistory(projectPath?: string, sessionId?: string): ReturnType | [] { return this.callbacks.getHistory?.(projectPath, sessionId) ?? []; } @@ -275,6 +290,10 @@ export class CallbackRegistry { this.callbacks.refreshAutoRunDocs = callback; } + setConfigureAutoRunCallback(callback: ConfigureAutoRunCallback): void { + this.callbacks.configureAutoRun = callback; + } + setGetHistoryCallback(callback: GetHistoryCallback): void { this.callbacks.getHistory = callback; } diff --git a/src/main/web-server/types.ts b/src/main/web-server/types.ts index f2820d436b..9b18ad960d 100644 --- a/src/main/web-server/types.ts +++ b/src/main/web-server/types.ts @@ -185,6 +185,35 @@ export interface CliActivity { startedAt: number; } +/** + * Auto Run document configuration received from CLI/web IPC. + */ +export interface ConfigureAutoRunDocument { + filename: string; + resetOnCompletion?: boolean; +} + +/** + * Auto Run configuration payload received from CLI/web IPC. + */ +export interface ConfigureAutoRunConfig { + documents: ConfigureAutoRunDocument[]; + prompt?: string; + loopEnabled?: boolean; + maxLoops?: number; + saveAsPlaybook?: string; + launch?: boolean; +} + +/** + * Response returned after forwarding an Auto Run configuration request. + */ +export interface ConfigureAutoRunResult { + success: boolean; + playbookId?: string; + error?: string; +} + // ============================================================================= // WebSocket Client Types // ============================================================================= @@ -296,6 +325,10 @@ export type ToggleBookmarkCallback = (sessionId: string) => Promise; export type OpenFileTabCallback = (sessionId: string, filePath: string) => Promise; export type RefreshFileTreeCallback = (sessionId: string) => Promise; export type RefreshAutoRunDocsCallback = (sessionId: string) => Promise; +export type ConfigureAutoRunCallback = ( + sessionId: string, + config: ConfigureAutoRunConfig +) => Promise; /** * Callback type for fetching current theme. diff --git a/src/main/web-server/web-server-factory.ts b/src/main/web-server/web-server-factory.ts index 33c041c380..02d5365738 100644 --- a/src/main/web-server/web-server-factory.ts +++ b/src/main/web-server/web-server-factory.ts @@ -13,6 +13,7 @@ import { isWebContentsAvailable } from '../utils/safe-send'; import type { ProcessManager } from '../process-manager'; import type { StoredSession, SettingsStoreInterface as SettingsStore } from '../stores/types'; import type { Group } from '../../shared/types'; +import type { ConfigureAutoRunConfig } from './types'; /** UUID v4 format regex for validating stored security tokens. * Enforces version nibble (4) and variant bits ([89ab]). */ @@ -563,6 +564,24 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) { return true; }); + server.setConfigureAutoRunCallback( + async (sessionId: string, config: ConfigureAutoRunConfig) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for configureAutoRun', 'WebServer'); + return { success: false, error: 'Main window is not available' }; + } + + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for configureAutoRun', 'WebServer'); + return { success: false, error: 'Renderer is not available' }; + } + + mainWindow.webContents.send('remote:configureAutoRun', sessionId, config); + return { success: true }; + } + ); + return server; }; } diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 0e71a3ddb7..e93d310f35 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -16,6 +16,15 @@ type AutoRunTreeNode = { children?: AutoRunTreeNode[]; }; +type ConfigureAutoRunConfig = { + documents: Array<{ filename: string; resetOnCompletion?: boolean }>; + prompt?: string; + loopEnabled?: boolean; + maxLoops?: number; + saveAsPlaybook?: string; + launch?: boolean; +}; + interface ProcessConfig { sessionId: string; toolType: string; @@ -324,6 +333,9 @@ interface MaestroAPI { onRemoteOpenFileTab: (callback: (sessionId: string, filePath: string) => void) => () => void; onRemoteRefreshFileTree: (callback: (sessionId: string) => void) => () => void; onRemoteRefreshAutoRunDocs: (callback: (sessionId: string) => void) => () => void; + onRemoteConfigureAutoRun: ( + callback: (sessionId: string, config: ConfigureAutoRunConfig) => void + ) => () => void; onStderr: (callback: (sessionId: string, data: string) => void) => () => void; onCommandExit: (callback: (sessionId: string, code: number) => void) => () => void; onUsage: (callback: (sessionId: string, usageStats: UsageStats) => void) => () => void; From 4c16fa27361c6abaf35c5e6a43639ccd145c1d0b Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 20:35:52 -0700 Subject: [PATCH 20/24] MAESTRO: Wire remote auto-run renderer handler --- .../web-server/web-server-factory.test.ts | 12 +- .../hooks/useRemoteIntegration.test.ts | 45 +++++ src/main/preload/process.ts | 26 ++- src/main/web-server/web-server-factory.ts | 35 +++- src/renderer/App.tsx | 161 +++++++++++++++++- src/renderer/components/AppModals.tsx | 6 + src/renderer/components/BatchRunnerModal.tsx | 24 ++- src/renderer/global.d.ts | 12 +- .../hooks/remote/useRemoteIntegration.ts | 26 +++ 9 files changed, 330 insertions(+), 17 deletions(-) diff --git a/src/__tests__/main/web-server/web-server-factory.test.ts b/src/__tests__/main/web-server/web-server-factory.test.ts index cfa7386b34..6f2cc1c62d 100644 --- a/src/__tests__/main/web-server/web-server-factory.test.ts +++ b/src/__tests__/main/web-server/web-server-factory.test.ts @@ -4,12 +4,14 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ipcMain } from 'electron'; import type { BrowserWindow, WebContents } from 'electron'; // Mock electron vi.mock('electron', () => ({ ipcMain: { once: vi.fn(), + removeListener: vi.fn(), }, })); @@ -689,13 +691,19 @@ describe('web-server/web-server-factory', () => { launch: true, }; - const result = await callback('session-1', config); + const resultPromise = callback('session-1', config); + + const responseChannel = (mockWebContents.send as ReturnType).mock.calls[0][3]; + const responseHandler = (ipcMain.once as ReturnType).mock.calls[0][1]; + responseHandler({} as never, { success: true }); + const result = await resultPromise; expect(result).toEqual({ success: true }); expect(mockWebContents.send).toHaveBeenCalledWith( 'remote:configureAutoRun', 'session-1', - config + config, + responseChannel ); }); }); diff --git a/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts b/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts index 452a104e7d..cd81f8b651 100644 --- a/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts +++ b/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts @@ -3,6 +3,15 @@ import { renderHook, act } from '@testing-library/react'; import { useRemoteIntegration } from '../../../renderer/hooks'; import type { Session, AITab } from '../../../renderer/types'; +type RemoteConfigureAutoRunConfig = { + documents: Array<{ filename: string; resetOnCompletion?: boolean }>; + prompt?: string; + loopEnabled?: boolean; + maxLoops?: number; + saveAsPlaybook?: string; + launch?: boolean; +}; + const createMockTab = (overrides: Partial = {}): AITab => ({ id: 'tab-1', agentSessionId: null, @@ -76,6 +85,9 @@ describe('useRemoteIntegration', () => { let onRemoteOpenFileTabHandler: ((sessionId: string, filePath: string) => void) | undefined; let onRemoteRefreshFileTreeHandler: ((sessionId: string) => void) | undefined; let onRemoteRefreshAutoRunDocsHandler: ((sessionId: string) => void) | undefined; + let onRemoteConfigureAutoRunHandler: + | ((sessionId: string, config: RemoteConfigureAutoRunConfig, responseChannel: string) => void) + | undefined; const mockProcess = { ...window.maestro.process, @@ -136,7 +148,12 @@ describe('useRemoteIntegration', () => { onRemoteRefreshAutoRunDocsHandler = handler; return () => {}; }), + onRemoteConfigureAutoRun: vi.fn().mockImplementation((handler) => { + onRemoteConfigureAutoRunHandler = handler; + return () => {}; + }), sendRemoteNewTabResponse: vi.fn(), + sendRemoteConfigureAutoRunResponse: vi.fn(), }; const mockLive = { @@ -182,6 +199,7 @@ describe('useRemoteIntegration', () => { onRemoteOpenFileTabHandler = undefined; onRemoteRefreshFileTreeHandler = undefined; onRemoteRefreshAutoRunDocsHandler = undefined; + onRemoteConfigureAutoRunHandler = undefined; window.maestro = { ...originalMaestro, @@ -525,6 +543,33 @@ describe('useRemoteIntegration', () => { dispatchEventSpy.mockRestore(); }); + + it('dispatches maestro:configureAutoRun when remote Auto Run config is received', () => { + const deps = createDeps(); + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent'); + const config = { + documents: [{ filename: 'task.md', resetOnCompletion: true }], + prompt: 'Run these tasks', + loopEnabled: true, + maxLoops: 2, + launch: true, + }; + + renderHook(() => useRemoteIntegration(deps)); + + act(() => { + onRemoteConfigureAutoRunHandler?.('session-1', config, 'remote:response:1'); + }); + + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'maestro:configureAutoRun', + detail: { sessionId: 'session-1', config, responseChannel: 'remote:response:1' }, + }) + ); + + dispatchEventSpy.mockRestore(); + }); }); describe('remote session selection', () => { diff --git a/src/main/preload/process.ts b/src/main/preload/process.ts index 5512c7d21c..bebc2ff510 100644 --- a/src/main/preload/process.ts +++ b/src/main/preload/process.ts @@ -141,6 +141,12 @@ export interface ConfigureAutoRunConfig { launch?: boolean; } +export interface ConfigureAutoRunResult { + success: boolean; + playbookId?: string; + error?: string; +} + /** * Creates the process API object for preload exposure */ @@ -462,14 +468,28 @@ export function createProcessApi() { * Subscribe to remote Auto Run configuration from web interface */ onRemoteConfigureAutoRun: ( - callback: (sessionId: string, config: ConfigureAutoRunConfig) => void + callback: (sessionId: string, config: ConfigureAutoRunConfig, responseChannel: string) => void ): (() => void) => { - const handler = (_: unknown, sessionId: string, config: ConfigureAutoRunConfig) => - callback(sessionId, config); + const handler = ( + _: unknown, + sessionId: string, + config: ConfigureAutoRunConfig, + responseChannel: string + ) => callback(sessionId, config, responseChannel); ipcRenderer.on('remote:configureAutoRun', handler); return () => ipcRenderer.removeListener('remote:configureAutoRun', handler); }, + /** + * Send response for remote Auto Run configuration. + */ + sendRemoteConfigureAutoRunResponse: ( + responseChannel: string, + result: ConfigureAutoRunResult + ): void => { + ipcRenderer.send(responseChannel, result); + }, + /** * Subscribe to stderr from runCommand (separate stream) */ diff --git a/src/main/web-server/web-server-factory.ts b/src/main/web-server/web-server-factory.ts index 02d5365738..bc36816a33 100644 --- a/src/main/web-server/web-server-factory.ts +++ b/src/main/web-server/web-server-factory.ts @@ -577,8 +577,39 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) { return { success: false, error: 'Renderer is not available' }; } - mainWindow.webContents.send('remote:configureAutoRun', sessionId, config); - return { success: true }; + return new Promise((resolve) => { + const responseChannel = `remote:configureAutoRun:response:${Date.now()}`; + let resolved = false; + + const handleResponse = ( + _event: Electron.IpcMainEvent, + result: { success: boolean; playbookId?: string; error?: string } + ) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result); + }; + + ipcMain.once(responseChannel, handleResponse); + mainWindow.webContents.send( + 'remote:configureAutoRun', + sessionId, + config, + responseChannel + ); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn( + `configureAutoRun callback timed out for session ${sessionId}`, + 'WebServer' + ); + resolve({ success: false, error: 'Timed out configuring Auto Run' }); + }, 5000); + }); } ); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 079e6fb406..1a108cb782 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -165,7 +165,15 @@ import { ToastContainer } from './components/Toast'; // 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 { getContextColor } from './utils/theme'; @@ -192,6 +200,21 @@ import { useUIStore } from './stores/uiStore'; 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(); @@ -681,11 +704,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]); // ProcessMonitor navigation handlers const handleProcessMonitorNavigateToSession = useCallback( @@ -1828,14 +1860,135 @@ function MaestroConsoleInner() { 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); + }; + 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) { + try { + 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 }); + } catch (error) { + sendResponse({ + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } + return; + } + + if (!session.autoRunFolderPath) { + sendResponse({ success: false, error: 'Session has no Auto Run folder configured' }); + return; + } + + if (config.launch) { + try { + await startBatchRunRef.current(sessionId, batchConfig, session.autoRunFolderPath); + sendResponse({ success: true }); + } catch (error) { + sendResponse({ + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } + 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 }); + }; + 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, @@ -1843,8 +1996,13 @@ function MaestroConsoleInner() { handleOpenFileTab, refreshFileTree, setActiveFocus, + setActiveRightTab, + setBatchRunnerModalOpen, + setRightPanelOpen, setActiveSessionId, + setSessions, sessionsRef, + setAutoRunDocumentList, ]); // --- FILE EXPLORER EFFECTS --- @@ -2818,6 +2976,7 @@ function MaestroConsoleInner() { onCloseBatchRunner={handleCloseBatchRunner} onStartBatchRun={handleStartBatchRun} onSaveBatchPrompt={handleSaveBatchPrompt} + initialBatchRunConfig={remoteBatchRunConfig} showConfirmation={showConfirmation} autoRunDocumentList={autoRunDocumentList} autoRunDocumentTree={autoRunDocumentTree} diff --git a/src/renderer/components/AppModals.tsx b/src/renderer/components/AppModals.tsx index 30bc8798bc..43437066c0 100644 --- a/src/renderer/components/AppModals.tsx +++ b/src/renderer/components/AppModals.tsx @@ -894,6 +894,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<{ @@ -1091,6 +1092,7 @@ export const AppUtilityModals = memo(function AppUtilityModals({ onCloseBatchRunner, onStartBatchRun, onSaveBatchPrompt, + initialBatchRunConfig, showConfirmation, autoRunDocumentList, autoRunDocumentTree, @@ -1298,6 +1300,7 @@ export const AppUtilityModals = memo(function AppUtilityModals({ onGo={onStartBatchRun} onSave={onSaveBatchPrompt} initialPrompt={activeSession.batchRunnerPrompt || ''} + initialConfig={initialBatchRunConfig || undefined} lastModifiedAt={activeSession.batchRunnerPromptModifiedAt} showConfirmation={showConfirmation} folderPath={activeSession.autoRunFolderPath} @@ -1989,6 +1992,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<{ @@ -2362,6 +2366,7 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) { onCloseBatchRunner, onStartBatchRun, onSaveBatchPrompt, + initialBatchRunConfig, showConfirmation, autoRunDocumentList, autoRunDocumentTree, @@ -2671,6 +2676,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/BatchRunnerModal.tsx b/src/renderer/components/BatchRunnerModal.tsx index e1ff1bef96..29b2ab2f4e 100644 --- a/src/renderer/components/BatchRunnerModal.tsx +++ b/src/renderer/components/BatchRunnerModal.tsx @@ -43,6 +43,7 @@ interface BatchRunnerModalProps { onGo: (config: BatchRunConfig) => void | Promise; onSave: (prompt: string) => void; initialPrompt?: string; + initialConfig?: Partial; lastModifiedAt?: number; showConfirmation: (message: string, onConfirm: () => void) => void; // Multi-document support @@ -88,6 +89,7 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { onGo, onSave, initialPrompt, + initialConfig, lastModifiedAt, showConfirmation, folderPath, @@ -118,6 +120,9 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { // Document list state const [documents, setDocuments] = useState(() => { + if (initialConfig?.documents?.length) { + return initialConfig.documents; + } // Initialize with current document if (currentDocument) { return [ @@ -133,29 +138,32 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { }); // Track initial document state for dirty checking - const initialDocumentsRef = useRef([currentDocument].filter(Boolean)); + const initialDocumentsRef = useRef( + initialConfig?.documents?.map((doc) => doc.filename) || [currentDocument].filter(Boolean) + ); // Task counts per document (keyed by filename) const [taskCounts, setTaskCounts] = useState>({}); const [loadingTaskCounts, setLoadingTaskCounts] = useState(true); // 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); // Prompt state - const [prompt, setPrompt] = useState(initialPrompt || DEFAULT_BATCH_PROMPT); + const resolvedInitialPrompt = initialConfig?.prompt || initialPrompt || DEFAULT_BATCH_PROMPT; + const [prompt, setPrompt] = useState(resolvedInitialPrompt); 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(resolvedInitialPrompt); // 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/global.d.ts b/src/renderer/global.d.ts index e93d310f35..5dd6c89c3e 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -25,6 +25,12 @@ type ConfigureAutoRunConfig = { launch?: boolean; }; +type ConfigureAutoRunResult = { + success: boolean; + playbookId?: string; + error?: string; +}; + interface ProcessConfig { sessionId: string; toolType: string; @@ -334,8 +340,12 @@ interface MaestroAPI { onRemoteRefreshFileTree: (callback: (sessionId: string) => void) => () => void; onRemoteRefreshAutoRunDocs: (callback: (sessionId: string) => void) => () => void; onRemoteConfigureAutoRun: ( - callback: (sessionId: string, config: ConfigureAutoRunConfig) => void + callback: (sessionId: string, config: ConfigureAutoRunConfig, responseChannel: string) => void ) => () => void; + sendRemoteConfigureAutoRunResponse: ( + responseChannel: string, + result: ConfigureAutoRunResult + ) => void; onStderr: (callback: (sessionId: string, data: string) => void) => () => void; onCommandExit: (callback: (sessionId: string, code: number) => void) => () => void; onUsage: (callback: (sessionId: string, usageStats: UsageStats) => void) => () => void; diff --git a/src/renderer/hooks/remote/useRemoteIntegration.ts b/src/renderer/hooks/remote/useRemoteIntegration.ts index 3c0fd71095..36e142887c 100644 --- a/src/renderer/hooks/remote/useRemoteIntegration.ts +++ b/src/renderer/hooks/remote/useRemoteIntegration.ts @@ -2,6 +2,15 @@ import { useEffect, useRef } from 'react'; import type { Session, SessionState, ThinkingMode } from '../../types'; import { createTab, closeTab } from '../../utils/tabHelpers'; +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. @@ -233,6 +242,23 @@ export function useRemoteIntegration(deps: UseRemoteIntegrationDeps): UseRemoteI }; }, []); + // 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(() => { From 07f0093455c54693de1e4b02076b1188eae39e31 Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 20:54:10 -0700 Subject: [PATCH 21/24] MAESTRO: Add auto-run CLI command --- src/cli/commands/auto-run.ts | 100 +++++++++++++++++++++++++++++++++++ src/cli/index.ts | 13 +++++ 2 files changed, 113 insertions(+) create mode 100644 src/cli/commands/auto-run.ts diff --git a/src/cli/commands/auto-run.ts b/src/cli/commands/auto-run.ts new file mode 100644 index 0000000000..b37596d71a --- /dev/null +++ b/src/cli/commands/auto-run.ts @@ -0,0 +1,100 @@ +// Auto Run command +// Configures or launches Auto Run in the running Maestro desktop app. + +import * as fs from 'fs'; +import * as path from 'path'; +import { resolveSessionId, withMaestroClient } from '../services/maestro-client'; +import { formatError } from '../output/formatter'; + +interface AutoRunOptions { + session?: string; + prompt?: string; + loop?: boolean; + maxLoops?: string; + saveAs?: string; + launch?: boolean; + resetOnCompletion?: boolean; +} + +interface ConfigureAutoRunResult { + type: 'configure_auto_run_result'; + success?: boolean; + playbookId?: string; + error?: string; +} + +function resolveMarkdownDocument(documentPathArg: string): string { + const documentPath = path.resolve(documentPathArg); + + if (!fs.existsSync(documentPath)) { + throw new Error(`Document not found: ${documentPath}`); + } + + const stats = fs.statSync(documentPath); + if (!stats.isFile()) { + throw new Error(`Document is not a file: ${documentPath}`); + } + + if (path.extname(documentPath).toLowerCase() !== '.md') { + throw new Error(`Document must be a Markdown file: ${documentPath}`); + } + + return documentPath; +} + +function parseMaxLoops(value: string | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + + const maxLoops = Number.parseInt(value, 10); + if (!Number.isInteger(maxLoops) || maxLoops < 1 || String(maxLoops) !== value) { + throw new Error('--max-loops must be a positive integer'); + } + + return maxLoops; +} + +export async function autoRun(documentPathArgs: string[], options: AutoRunOptions): Promise { + try { + const documentPaths = documentPathArgs.map(resolveMarkdownDocument); + const maxLoops = parseMaxLoops(options.maxLoops); + const sessionId = resolveSessionId(options); + const documents = documentPaths.map((documentPath) => ({ + filename: path.basename(documentPath), + resetOnCompletion: options.resetOnCompletion || false, + })); + + await withMaestroClient(async (client) => { + const result = await client.sendCommand( + { + type: 'configure_auto_run', + sessionId, + documents, + prompt: options.prompt, + loopEnabled: options.loop || maxLoops !== undefined, + maxLoops, + saveAsPlaybook: options.saveAs, + launch: options.launch || false, + }, + 'configure_auto_run_result' + ); + + if (result.success === false) { + throw new Error(result.error || 'Failed to configure Auto Run'); + } + }); + + if (options.saveAs) { + console.log(`Playbook '${options.saveAs}' saved`); + } else if (options.launch) { + console.log(`Auto-run launched with ${documents.length} documents`); + } else { + console.log(`Auto-run configured with ${documents.length} documents`); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(formatError(`Failed to configure Auto Run: ${message}`)); + process.exit(1); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 8a219a576c..dfefad7ee0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -12,6 +12,7 @@ import { showPlaybook } from './commands/show-playbook'; import { showAgent } from './commands/show-agent'; import { cleanPlaybooks } from './commands/clean-playbooks'; import { send } from './commands/send'; +import { autoRun } from './commands/auto-run'; import { openFile } from './commands/open-file'; import { refreshFiles } from './commands/refresh-files'; import { refreshAutoRun } from './commands/refresh-auto-run'; @@ -125,6 +126,18 @@ program .option('-t, --tab', 'Open/focus the session tab in Maestro desktop') .action(send); +program + .command('auto-run ') + .description('Configure or launch Auto Run in the Maestro desktop app') + .option('-s, --session ', 'Target session (defaults to active)') + .option('-p, --prompt ', 'Custom prompt for the Auto Run') + .option('--loop', 'Enable looping') + .option('--max-loops ', 'Maximum loop count (implies --loop)') + .option('--save-as ', "Save as a playbook with this name (don't launch)") + .option('--launch', 'Start the Auto Run immediately') + .option('--reset-on-completion', 'Enable reset-on-completion for all documents') + .action(autoRun); + program .command('open-file ') .description('Open a file as a preview tab in the Maestro desktop app') From 0116530a0a4ddbb283097085a5ecc98a236a22a6 Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 20:58:32 -0700 Subject: [PATCH 22/24] MAESTRO: Document CLI IPC commands in system prompt --- src/prompts/maestro-system-prompt.md | 42 ++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/prompts/maestro-system-prompt.md b/src/prompts/maestro-system-prompt.md index 90c2dd2cba..883eddfa7b 100644 --- a/src/prompts/maestro-system-prompt.md +++ b/src/prompts/maestro-system-prompt.md @@ -74,6 +74,48 @@ When a user wants an auto-run document (or playbook), create a detailed multi-do **Note:** The Auto Run folder may be located outside your working directory (e.g., in a parent repository when you are in a worktree). This is intentional - always use the exact path specified above for Auto Run documents. +## 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 **You MUST only write files within your assigned working directory:** From f844243afc8516ae2d5a91827bf108e3186d330a Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Thu, 21 May 2026 21:04:10 -0700 Subject: [PATCH 23/24] MAESTRO: Add auto-run CLI IPC tests --- src/__tests__/cli/commands/auto-run.test.ts | 165 ++++++++++++++++++ .../handlers/messageHandlers.test.ts | 74 ++++++++ 2 files changed, 239 insertions(+) create mode 100644 src/__tests__/cli/commands/auto-run.test.ts diff --git a/src/__tests__/cli/commands/auto-run.test.ts b/src/__tests__/cli/commands/auto-run.test.ts new file mode 100644 index 0000000000..0d7367bc99 --- /dev/null +++ b/src/__tests__/cli/commands/auto-run.test.ts @@ -0,0 +1,165 @@ +/** + * @file auto-run.test.ts + * @description Tests for the auto-run CLI command + */ + +import { describe, it, expect, vi, beforeEach, type MockInstance } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; + +vi.mock('fs', () => ({ + existsSync: vi.fn(), + statSync: vi.fn(), +})); + +vi.mock('../../../cli/services/maestro-client', () => ({ + resolveSessionId: vi.fn(), + withMaestroClient: vi.fn(), +})); + +vi.mock('../../../cli/output/formatter', () => ({ + formatError: vi.fn((message: string) => `Error: ${message}`), +})); + +import { autoRun } from '../../../cli/commands/auto-run'; +import { resolveSessionId, withMaestroClient } from '../../../cli/services/maestro-client'; + +describe('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(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true } as fs.Stats); + vi.mocked(resolveSessionId).mockReturnValue('target-session'); + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const client = { + sendCommand: vi.fn().mockResolvedValue({ + type: 'configure_auto_run_result', + success: true, + }), + }; + return action(client as never); + }); + }); + + it('configures Auto Run with valid document paths', async () => { + await autoRun(['docs/first.md', 'docs/second.md'], { session: 'target-session' }); + + expect(resolveSessionId).toHaveBeenCalledWith({ session: 'target-session' }); + expect(withMaestroClient).toHaveBeenCalledTimes(1); + const action = vi.mocked(withMaestroClient).mock.calls[0][0]; + const sendCommand = vi.fn().mockResolvedValue({ + type: 'configure_auto_run_result', + success: true, + }); + await action({ sendCommand } as never); + + expect(sendCommand).toHaveBeenCalledWith( + { + type: 'configure_auto_run', + sessionId: 'target-session', + documents: [ + { filename: 'first.md', resetOnCompletion: false }, + { filename: 'second.md', resetOnCompletion: false }, + ], + prompt: undefined, + loopEnabled: false, + maxLoops: undefined, + saveAsPlaybook: undefined, + launch: false, + }, + 'configure_auto_run_result' + ); + expect(consoleSpy).toHaveBeenCalledWith('Auto-run configured with 2 documents'); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('exits with an error for a non-existent document', async () => { + const missingPath = path.resolve('docs/missing.md'); + vi.mocked(fs.existsSync).mockReturnValue(false); + + await autoRun(['docs/missing.md'], { session: 'target-session' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Error: Failed to configure Auto Run: Document not found: ${missingPath}` + ); + expect(withMaestroClient).not.toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('sends saveAsPlaybook when saving as a playbook', async () => { + await autoRun(['docs/play.md'], { session: 'target-session', saveAs: 'Daily Review' }); + + const action = vi.mocked(withMaestroClient).mock.calls[0][0]; + const sendCommand = vi.fn().mockResolvedValue({ + type: 'configure_auto_run_result', + success: true, + playbookId: 'playbook-1', + }); + await action({ sendCommand } as never); + + expect(sendCommand).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'configure_auto_run', + saveAsPlaybook: 'Daily Review', + launch: false, + }), + 'configure_auto_run_result' + ); + expect(consoleSpy).toHaveBeenCalledWith("Playbook 'Daily Review' saved"); + }); + + it('sends launch true when launching Auto Run', async () => { + await autoRun(['docs/launch.md'], { session: 'target-session', launch: true }); + + const action = vi.mocked(withMaestroClient).mock.calls[0][0]; + const sendCommand = vi.fn().mockResolvedValue({ + type: 'configure_auto_run_result', + success: true, + }); + await action({ sendCommand } as never); + + expect(sendCommand).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'configure_auto_run', + launch: true, + }), + 'configure_auto_run_result' + ); + expect(consoleSpy).toHaveBeenCalledWith('Auto-run launched with 1 documents'); + }); + + it('sends loop configuration for --loop and --max-loops', async () => { + await autoRun(['docs/loop.md'], { + session: 'target-session', + loop: true, + maxLoops: '3', + resetOnCompletion: true, + prompt: 'Keep going until clean', + }); + + const action = vi.mocked(withMaestroClient).mock.calls[0][0]; + const sendCommand = vi.fn().mockResolvedValue({ + type: 'configure_auto_run_result', + success: true, + }); + await action({ sendCommand } as never); + + expect(sendCommand).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'configure_auto_run', + documents: [{ filename: 'loop.md', resetOnCompletion: true }], + prompt: 'Keep going until clean', + loopEnabled: true, + maxLoops: 3, + }), + 'configure_auto_run_result' + ); + }); +}); diff --git a/src/__tests__/main/web-server/handlers/messageHandlers.test.ts b/src/__tests__/main/web-server/handlers/messageHandlers.test.ts index 80b64c5084..c7b978f9dd 100644 --- a/src/__tests__/main/web-server/handlers/messageHandlers.test.ts +++ b/src/__tests__/main/web-server/handlers/messageHandlers.test.ts @@ -72,6 +72,7 @@ function createMockCallbacks(): MessageHandlerCallbacks { openFileTab: vi.fn().mockResolvedValue(true), refreshFileTree: vi.fn().mockResolvedValue(true), refreshAutoRunDocs: vi.fn().mockResolvedValue(true), + configureAutoRun: vi.fn().mockResolvedValue({ success: true }), getSessions: vi.fn().mockReturnValue([ { id: 'session-1', @@ -748,6 +749,79 @@ describe('WebSocketMessageHandler', () => { }); }); + describe('Configure Auto Run (Web → Desktop)', () => { + it('should configure Auto Run on desktop with valid config', async () => { + handler.handleMessage(client, { + type: 'configure_auto_run', + sessionId: 'session-1', + documents: [{ filename: 'first.md', resetOnCompletion: true }, { filename: 'second.md' }], + prompt: 'Run these tasks', + loopEnabled: true, + maxLoops: 2, + launch: true, + }); + + await vi.waitFor(() => { + expect(callbacks.configureAutoRun).toHaveBeenCalledWith('session-1', { + documents: [{ filename: 'first.md', resetOnCompletion: true }, { filename: 'second.md' }], + prompt: 'Run these tasks', + loopEnabled: true, + maxLoops: 2, + saveAsPlaybook: undefined, + launch: true, + }); + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('configure_auto_run_result'); + expect(response.success).toBe(true); + }); + + it('should reject configure Auto Run with missing documents', () => { + handler.handleMessage(client, { + type: 'configure_auto_run', + sessionId: 'session-1', + documents: [], + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('configure_auto_run_result'); + expect(response.success).toBe(false); + expect(response.error).toContain('Missing documents'); + expect(callbacks.configureAutoRun).not.toHaveBeenCalled(); + }); + + it('should configure Auto Run with saveAsPlaybook set', async () => { + (callbacks.configureAutoRun as any).mockResolvedValue({ + success: true, + playbookId: 'playbook-123', + }); + + handler.handleMessage(client, { + type: 'configure_auto_run', + sessionId: 'session-1', + documents: [{ filename: 'playbook.md' }], + saveAsPlaybook: 'Saved Playbook', + }); + + await vi.waitFor(() => { + expect(callbacks.configureAutoRun).toHaveBeenCalledWith('session-1', { + documents: [{ filename: 'playbook.md' }], + prompt: undefined, + loopEnabled: undefined, + maxLoops: undefined, + saveAsPlaybook: 'Saved Playbook', + launch: undefined, + }); + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('configure_auto_run_result'); + expect(response.success).toBe(true); + expect(response.playbookId).toBe('playbook-123'); + }); + }); + describe('Unknown Message Types', () => { it('should echo unknown message types for debugging', () => { handler.handleMessage(client, { From d5a098c19911e64824fcff730e9c10fed6967bc2 Mon Sep 17 00:00:00 2001 From: MAXIMUS Date: Fri, 22 May 2026 12:41:46 -0700 Subject: [PATCH 24/24] Fix CLI IPC review issues --- src/__tests__/cli/commands/auto-run.test.ts | 34 ++++ .../cli/services/maestro-client.test.ts | 74 +++++++++ src/__tests__/main/ipc/handlers/web.test.ts | 47 +++++- .../handlers/messageHandlers.test.ts | 21 ++- .../shared/cli-server-discovery.test.ts | 28 +++- src/cli/commands/auto-run.ts | 28 +++- src/cli/commands/send.ts | 1 - src/cli/services/maestro-client.ts | 40 ++++- src/main/ipc/handlers/web.ts | 21 ++- .../web-server/handlers/messageHandlers.ts | 144 +++++++++++------ src/main/web-server/types.ts | 1 + src/main/web-server/web-server-factory.ts | 4 +- src/renderer/App.tsx | 152 +++++++++--------- src/shared/cli-server-discovery.ts | 24 ++- 14 files changed, 463 insertions(+), 156 deletions(-) diff --git a/src/__tests__/cli/commands/auto-run.test.ts b/src/__tests__/cli/commands/auto-run.test.ts index 0d7367bc99..2b6d58c474 100644 --- a/src/__tests__/cli/commands/auto-run.test.ts +++ b/src/__tests__/cli/commands/auto-run.test.ts @@ -17,12 +17,17 @@ vi.mock('../../../cli/services/maestro-client', () => ({ withMaestroClient: vi.fn(), })); +vi.mock('../../../cli/services/storage', () => ({ + getSessionById: vi.fn(), +})); + vi.mock('../../../cli/output/formatter', () => ({ formatError: vi.fn((message: string) => `Error: ${message}`), })); import { autoRun } from '../../../cli/commands/auto-run'; import { resolveSessionId, withMaestroClient } from '../../../cli/services/maestro-client'; +import { getSessionById } from '../../../cli/services/storage'; describe('auto-run command', () => { let consoleSpy: MockInstance; @@ -37,6 +42,14 @@ describe('auto-run command', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true } as fs.Stats); vi.mocked(resolveSessionId).mockReturnValue('target-session'); + vi.mocked(getSessionById).mockReturnValue({ + id: 'target-session', + name: 'Target Session', + toolType: 'codex', + cwd: path.resolve('.'), + projectRoot: path.resolve('.'), + autoRunFolderPath: path.resolve('docs'), + }); vi.mocked(withMaestroClient).mockImplementation(async (action) => { const client = { sendCommand: vi.fn().mockResolvedValue({ @@ -162,4 +175,25 @@ describe('auto-run command', () => { 'configure_auto_run_result' ); }); + + it('exits with an error when a document is outside the Auto Run folder', async () => { + await autoRun(['other/task.md'], { session: 'target-session' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Document must be in the session Auto Run folder') + ); + expect(withMaestroClient).not.toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('treats a missing success field as a failed response', async () => { + await autoRun(['docs/task.md'], { session: 'target-session' }); + + const action = vi.mocked(withMaestroClient).mock.calls[0][0]; + const sendCommand = vi.fn().mockResolvedValue({ + type: 'configure_auto_run_result', + }); + + await expect(action({ sendCommand } as never)).rejects.toThrow('Failed to configure Auto Run'); + }); }); diff --git a/src/__tests__/cli/services/maestro-client.test.ts b/src/__tests__/cli/services/maestro-client.test.ts index 817098089e..b6cb6f012d 100644 --- a/src/__tests__/cli/services/maestro-client.test.ts +++ b/src/__tests__/cli/services/maestro-client.test.ts @@ -146,6 +146,80 @@ describe('MaestroClient', () => { await expect(responsePromise).resolves.toMatchObject({ type: 'pong', ok: true }); }); + it('sendCommand() matches responses by requestId before response type', async () => { + const client = new MaestroClient(); + const connectPromise = client.connect(); + const ws = wsMock.instances[0]; + ws.emit('open'); + await connectPromise; + + const firstPromise = client.sendCommand<{ value: string }>({ type: 'ping' }, 'pong'); + const firstPayload = JSON.parse(ws.sent[0]) as { requestId: string }; + const secondPromise = client.sendCommand<{ value: string }>({ type: 'ping' }, 'pong'); + const secondPayload = JSON.parse(ws.sent[1]) as { requestId: string }; + + ws.emit( + 'message', + Buffer.from( + JSON.stringify({ type: 'pong', requestId: secondPayload.requestId, value: 'second' }) + ) + ); + + await expect(secondPromise).resolves.toMatchObject({ value: 'second' }); + + ws.emit( + 'message', + Buffer.from( + JSON.stringify({ type: 'pong', requestId: firstPayload.requestId, value: 'first' }) + ) + ); + + await expect(firstPromise).resolves.toMatchObject({ value: 'first' }); + }); + + it('rejects ambiguous same-type responses that omit requestId', async () => { + const client = new MaestroClient(); + const connectPromise = client.connect(); + const ws = wsMock.instances[0]; + ws.emit('open'); + await connectPromise; + + const firstPromise = client.sendCommand({ type: 'ping' }, 'pong'); + const secondPromise = client.sendCommand({ type: 'ping' }, 'pong'); + + ws.emit('message', Buffer.from(JSON.stringify({ type: 'pong', value: 'ambiguous' }))); + + await expect(firstPromise).rejects.toThrow('Protocol error'); + await expect(secondPromise).rejects.toThrow('Protocol error'); + }); + + it('keeps single-request compatibility for responses that omit requestId', async () => { + const client = new MaestroClient(); + const connectPromise = client.connect(); + const ws = wsMock.instances[0]; + ws.emit('open'); + await connectPromise; + + const responsePromise = client.sendCommand<{ value: string }>({ type: 'ping' }, 'pong'); + + ws.emit('message', Buffer.from(JSON.stringify({ type: 'pong', value: 'legacy' }))); + + await expect(responsePromise).resolves.toMatchObject({ value: 'legacy' }); + }); + + it('rejects pending requests and surfaces malformed JSON responses', async () => { + const client = new MaestroClient(); + const connectPromise = client.connect(); + const ws = wsMock.instances[0]; + ws.emit('open'); + await connectPromise; + + const responsePromise = client.sendCommand({ type: 'ping' }, 'pong'); + + expect(() => ws.emit('message', Buffer.from('{not-json'))).toThrow(); + await expect(responsePromise).rejects.toThrow('Invalid message from Maestro desktop app'); + }); + it('sendCommand() rejects on timeout', async () => { vi.useFakeTimers(); const client = new MaestroClient(); diff --git a/src/__tests__/main/ipc/handlers/web.test.ts b/src/__tests__/main/ipc/handlers/web.test.ts index 7e5a34a3dd..0ff2e398de 100644 --- a/src/__tests__/main/ipc/handlers/web.test.ts +++ b/src/__tests__/main/ipc/handlers/web.test.ts @@ -30,11 +30,12 @@ vi.mock('../../../../main/web-server', () => ({ vi.mock('../../../../shared/cli-server-discovery', () => ({ deleteCliServerInfo: vi.fn(), + readCliServerInfo: vi.fn(), writeCliServerInfo: vi.fn(), })); import { registerWebHandlers } from '../../../../main/ipc/handlers/web'; -import { deleteCliServerInfo, writeCliServerInfo } from '../../../../shared/cli-server-discovery'; +import { readCliServerInfo, writeCliServerInfo } from '../../../../shared/cli-server-discovery'; describe('web handlers', () => { let mockWebServer: any; @@ -77,6 +78,7 @@ describe('web handlers', () => { get: vi.fn(), set: vi.fn(), }; + vi.mocked(readCliServerInfo).mockReturnValue(null); registerWebHandlers({ getWebServer: () => webServerRef.current, @@ -314,6 +316,35 @@ describe('web handlers', () => { const result = await handler!({}); expect(mockWebServer.start).not.toHaveBeenCalled(); + expect(writeCliServerInfo).toHaveBeenCalledWith({ + port: 8080, + token: 'mock-security-token', + pid: process.pid, + startedAt: expect.any(Number), + }); + expect(result).toEqual({ success: true, url: 'http://localhost:8080' }); + }); + + it('should preserve startedAt when rewriting discovery for an already running server', async () => { + const startedAt = 1710000000000; + mockWebServer.isActive.mockReturnValue(true); + vi.mocked(readCliServerInfo).mockReturnValue({ + port: 8080, + token: 'mock-security-token', + pid: process.pid, + startedAt, + }); + + const handler = registeredHandlers.get('live:startServer'); + const result = await handler!({}); + + expect(mockWebServer.start).not.toHaveBeenCalled(); + expect(writeCliServerInfo).toHaveBeenCalledWith({ + port: 8080, + token: 'mock-security-token', + pid: process.pid, + startedAt, + }); expect(result).toEqual({ success: true, url: 'http://localhost:8080' }); }); @@ -329,13 +360,15 @@ describe('web handlers', () => { }); describe('live:stopServer', () => { - it('should stop web server and clean up', async () => { + it('should stop live server and restart CLI IPC server', async () => { + mockWebServer.isActive.mockReturnValue(false); const handler = registeredHandlers.get('live:stopServer'); const result = await handler!({}); expect(mockWebServer.stop).toHaveBeenCalled(); - expect(deleteCliServerInfo).toHaveBeenCalled(); - expect(webServerRef.current).toBeNull(); + expect(mockCreateWebServer).toHaveBeenCalled(); + expect(mockWebServer.start).toHaveBeenCalled(); + expect(webServerRef.current).toBe(mockWebServer); expect(result).toEqual({ success: true }); }); @@ -351,6 +384,7 @@ describe('web handlers', () => { describe('live:disableAll', () => { it('should disable all live sessions and stop server', async () => { + mockWebServer.isActive.mockReturnValue(false); mockWebServer.getLiveSessions.mockReturnValue([ { sessionId: 'session-1' }, { sessionId: 'session-2' }, @@ -362,8 +396,9 @@ describe('web handlers', () => { expect(mockWebServer.setSessionOffline).toHaveBeenCalledWith('session-1'); expect(mockWebServer.setSessionOffline).toHaveBeenCalledWith('session-2'); expect(mockWebServer.stop).toHaveBeenCalled(); - expect(deleteCliServerInfo).toHaveBeenCalled(); - expect(webServerRef.current).toBeNull(); + expect(mockCreateWebServer).toHaveBeenCalled(); + expect(mockWebServer.start).toHaveBeenCalled(); + expect(webServerRef.current).toBe(mockWebServer); expect(result).toEqual({ success: true, count: 2 }); }); diff --git a/src/__tests__/main/web-server/handlers/messageHandlers.test.ts b/src/__tests__/main/web-server/handlers/messageHandlers.test.ts index c7b978f9dd..d0aae9e0c4 100644 --- a/src/__tests__/main/web-server/handlers/messageHandlers.test.ts +++ b/src/__tests__/main/web-server/handlers/messageHandlers.test.ts @@ -102,11 +102,12 @@ describe('WebSocketMessageHandler', () => { describe('Ping/Pong Health Check', () => { it('should respond to ping with pong', () => { - handler.handleMessage(client, { type: 'ping' }); + handler.handleMessage(client, { type: 'ping', requestId: 'request-1' }); expect(client.socket.send).toHaveBeenCalledTimes(1); const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); expect(response.type).toBe('pong'); + expect(response.requestId).toBe('request-1'); expect(response.timestamp).toBeDefined(); }); }); @@ -753,6 +754,7 @@ describe('WebSocketMessageHandler', () => { it('should configure Auto Run on desktop with valid config', async () => { handler.handleMessage(client, { type: 'configure_auto_run', + requestId: 'configure-request-1', sessionId: 'session-1', documents: [{ filename: 'first.md', resetOnCompletion: true }, { filename: 'second.md' }], prompt: 'Run these tasks', @@ -774,6 +776,7 @@ describe('WebSocketMessageHandler', () => { const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); expect(response.type).toBe('configure_auto_run_result'); + expect(response.requestId).toBe('configure-request-1'); expect(response.success).toBe(true); }); @@ -787,7 +790,21 @@ describe('WebSocketMessageHandler', () => { const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); expect(response.type).toBe('configure_auto_run_result'); expect(response.success).toBe(false); - expect(response.error).toContain('Missing documents'); + expect(response.error).toContain('Invalid documents payload'); + expect(callbacks.configureAutoRun).not.toHaveBeenCalled(); + }); + + it('should reject configure Auto Run with malformed documents', () => { + handler.handleMessage(client, { + type: 'configure_auto_run', + sessionId: 'session-1', + documents: [{ resetOnCompletion: true }], + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('configure_auto_run_result'); + expect(response.success).toBe(false); + expect(response.error).toContain('Invalid documents payload'); expect(callbacks.configureAutoRun).not.toHaveBeenCalled(); }); diff --git a/src/__tests__/shared/cli-server-discovery.test.ts b/src/__tests__/shared/cli-server-discovery.test.ts index ca9161dacf..6b585d63a5 100644 --- a/src/__tests__/shared/cli-server-discovery.test.ts +++ b/src/__tests__/shared/cli-server-discovery.test.ts @@ -12,6 +12,8 @@ vi.mock('fs', () => ({ writeFileSync: vi.fn(), existsSync: vi.fn(), mkdirSync: vi.fn(), + statSync: vi.fn(), + chmodSync: vi.fn(), renameSync: vi.fn(), unlinkSync: vi.fn(), })); @@ -38,6 +40,8 @@ const mockFs = { writeFileSync: fs.writeFileSync as ReturnType, existsSync: fs.existsSync as ReturnType, mkdirSync: fs.mkdirSync as ReturnType, + statSync: fs.statSync as ReturnType, + chmodSync: fs.chmodSync as ReturnType, renameSync: fs.renameSync as ReturnType, unlinkSync: fs.unlinkSync as ReturnType, }; @@ -66,6 +70,8 @@ describe('cli-server-discovery', () => { mockFs.readFileSync.mockReturnValue(JSON.stringify(sampleInfo)); mockFs.writeFileSync.mockReturnValue(undefined); mockFs.mkdirSync.mockReturnValue(undefined); + mockFs.statSync.mockReturnValue({ mode: 0o700 } as fs.Stats); + mockFs.chmodSync.mockReturnValue(undefined); mockFs.renameSync.mockReturnValue(undefined); mockFs.unlinkSync.mockReturnValue(undefined); @@ -82,13 +88,23 @@ describe('cli-server-discovery', () => { writeCliServerInfo(sampleInfo); - expect(mockFs.mkdirSync).toHaveBeenCalledWith(configDir, { recursive: true }); + expect(mockFs.mkdirSync).toHaveBeenCalledWith(configDir, { recursive: true, mode: 0o700 }); expect(mockFs.writeFileSync).toHaveBeenCalledWith( `${serverFile}.tmp`, JSON.stringify(sampleInfo, null, 2), - 'utf-8' + { encoding: 'utf-8', mode: 0o600 } ); + expect(mockFs.chmodSync).toHaveBeenCalledWith(`${serverFile}.tmp`, 0o600); expect(mockFs.renameSync).toHaveBeenCalledWith(`${serverFile}.tmp`, serverFile); + expect(mockFs.chmodSync).toHaveBeenCalledWith(serverFile, 0o600); + }); + + it('restricts an existing config directory when it is group or world accessible', () => { + mockFs.statSync.mockReturnValue({ mode: 0o755 } as fs.Stats); + + writeCliServerInfo(sampleInfo); + + expect(mockFs.chmodSync).toHaveBeenCalledWith(configDir, 0o700); }); }); @@ -113,6 +129,14 @@ describe('cli-server-discovery', () => { expect(readCliServerInfo()).toBeNull(); }); + + it('returns null for non-integer or out-of-range discovery data', () => { + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ ...sampleInfo, port: 70000, pid: 0, startedAt: 1.5 }) + ); + + expect(readCliServerInfo()).toBeNull(); + }); }); describe('deleteCliServerInfo', () => { diff --git a/src/cli/commands/auto-run.ts b/src/cli/commands/auto-run.ts index b37596d71a..9956c2a322 100644 --- a/src/cli/commands/auto-run.ts +++ b/src/cli/commands/auto-run.ts @@ -4,6 +4,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { resolveSessionId, withMaestroClient } from '../services/maestro-client'; +import { getSessionById } from '../services/storage'; import { formatError } from '../output/formatter'; interface AutoRunOptions { @@ -18,7 +19,7 @@ interface AutoRunOptions { interface ConfigureAutoRunResult { type: 'configure_auto_run_result'; - success?: boolean; + success: boolean; playbookId?: string; error?: string; } @@ -60,8 +61,17 @@ export async function autoRun(documentPathArgs: string[], options: AutoRunOption const documentPaths = documentPathArgs.map(resolveMarkdownDocument); const maxLoops = parseMaxLoops(options.maxLoops); const sessionId = resolveSessionId(options); + const session = getSessionById(sessionId); + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + if (!session.autoRunFolderPath) { + throw new Error(`Session has no Auto Run folder configured: ${sessionId}`); + } + + const autoRunFolderPath = path.resolve(session.autoRunFolderPath); const documents = documentPaths.map((documentPath) => ({ - filename: path.basename(documentPath), + filename: getAutoRunDocumentFilename(documentPath, autoRunFolderPath), resetOnCompletion: options.resetOnCompletion || false, })); @@ -80,7 +90,7 @@ export async function autoRun(documentPathArgs: string[], options: AutoRunOption 'configure_auto_run_result' ); - if (result.success === false) { + if (!result.success) { throw new Error(result.error || 'Failed to configure Auto Run'); } }); @@ -98,3 +108,15 @@ export async function autoRun(documentPathArgs: string[], options: AutoRunOption process.exit(1); } } + +function getAutoRunDocumentFilename(documentPath: string, autoRunFolderPath: string): string { + const resolvedDocumentPath = path.resolve(documentPath); + const documentDir = path.dirname(resolvedDocumentPath); + if (documentDir !== autoRunFolderPath) { + throw new Error( + `Document must be in the session Auto Run folder: ${autoRunFolderPath}. Received: ${resolvedDocumentPath}` + ); + } + + return path.basename(resolvedDocumentPath); +} diff --git a/src/cli/commands/send.ts b/src/cli/commands/send.ts index 34cf189d16..4e71586ebd 100644 --- a/src/cli/commands/send.ts +++ b/src/cli/commands/send.ts @@ -121,7 +121,6 @@ export async function send( if (!result.success) { process.exit(1); - return; } if (options.tab) { diff --git a/src/cli/services/maestro-client.ts b/src/cli/services/maestro-client.ts index 8938cbade6..e8b5ae0bf3 100644 --- a/src/cli/services/maestro-client.ts +++ b/src/cli/services/maestro-client.ts @@ -133,8 +133,9 @@ export class MaestroClient { let message: MaestroMessage; try { message = JSON.parse(data.toString()) as MaestroMessage; - } catch { - return; + } catch (error) { + this.rejectAllPending(new Error('Invalid message from Maestro desktop app')); + throw error; } if (message.type === 'error') { @@ -142,9 +143,38 @@ export class MaestroClient { return; } - for (const [requestId, pending] of this.pendingRequests) { - if (message.type !== pending.responseType) continue; - if (message.requestId && message.requestId !== requestId) continue; + if (message.requestId) { + const pending = this.pendingRequests.get(message.requestId); + if (!pending || message.type !== pending.responseType) return; + + clearTimeout(pending.timeout); + this.pendingRequests.delete(message.requestId); + + if (message.success === false) { + pending.reject(new Error(this.getErrorMessage(message))); + } else { + pending.resolve(message); + } + return; + } + + const matchingRequests = [...this.pendingRequests.entries()].filter( + ([, pending]) => message.type === pending.responseType + ); + + if (matchingRequests.length > 1) { + const error = new Error(`Protocol error: response ${message.type} is missing requestId`); + for (const [requestId, pending] of matchingRequests) { + clearTimeout(pending.timeout); + this.pendingRequests.delete(requestId); + pending.reject(error); + } + return; + } + + const [match] = matchingRequests; + if (match) { + const [requestId, pending] = match; clearTimeout(pending.timeout); this.pendingRequests.delete(requestId); diff --git a/src/main/ipc/handlers/web.ts b/src/main/ipc/handlers/web.ts index ea35261b9d..8858d9c58a 100644 --- a/src/main/ipc/handlers/web.ts +++ b/src/main/ipc/handlers/web.ts @@ -27,7 +27,7 @@ import { logger } from '../../utils/logger'; import { WebServer } from '../../web-server'; import type { AITabData } from '../../web-server/services/broadcastService'; import type { SettingsStoreInterface } from '../../stores/types'; -import { deleteCliServerInfo, writeCliServerInfo } from '../../../shared/cli-server-discovery'; +import { readCliServerInfo, writeCliServerInfo } from '../../../shared/cli-server-discovery'; /** * Timeout for waiting for web server to become active (ms) @@ -54,6 +54,7 @@ export interface WebHandlerDependencies { */ export async function ensureCliServer(deps: WebHandlerDependencies): Promise { let webServer = deps.getWebServer(); + let startedAt: number | undefined; if (!webServer) { logger.info('Creating CLI web server', 'WebServer'); @@ -66,13 +67,23 @@ export async function ensureCliServer(deps: WebHandlerDependencies): Promise 0 && + documents.every( + (document) => + !!document && + typeof document === 'object' && + typeof document.filename === 'string' && + document.filename.trim().length > 0 && + (document.resetOnCompletion === undefined || + typeof document.resetOnCompletion === 'boolean') + ) + ); +} + /** * Web client message interface */ export interface WebClientMessage { type: string; + requestId?: string; sessionId?: string; tabId?: string; command?: string; @@ -131,15 +150,23 @@ export class WebSocketMessageHandler { /** * Helper to send a JSON message to a client with timestamp */ - private send(client: WebClient, data: Record): void { - client.socket.send(JSON.stringify({ ...data, timestamp: Date.now() })); + private send(client: WebClient, data: Record, message?: WebClientMessage): void { + const requestId = typeof message?.requestId === 'string' ? message.requestId : undefined; + client.socket.send( + JSON.stringify({ ...data, ...(requestId ? { requestId } : {}), timestamp: Date.now() }) + ); } /** * Helper to send an error message to a client */ - private sendError(client: WebClient, message: string, extra?: Record): void { - this.send(client, { type: 'error', message, ...extra }); + private sendError( + client: WebClient, + errorMessage: string, + extra?: Record, + message?: WebClientMessage + ): void { + this.send(client, { type: 'error', message: errorMessage, ...extra }, message); } /** @@ -157,7 +184,7 @@ export class WebSocketMessageHandler { switch (message.type) { case 'ping': - this.handlePing(client); + this.handlePing(client, message); break; case 'subscribe': @@ -177,7 +204,7 @@ export class WebSocketMessageHandler { break; case 'get_sessions': - this.handleGetSessions(client); + this.handleGetSessions(client, message); break; case 'select_tab': @@ -232,8 +259,8 @@ export class WebSocketMessageHandler { /** * Handle ping message - respond with pong */ - private handlePing(client: WebClient): void { - this.send(client, { type: 'pong' }); + private handlePing(client: WebClient, message: WebClientMessage): void { + this.send(client, { type: 'pong' }, message); } /** @@ -380,13 +407,13 @@ export class WebSocketMessageHandler { ); if (!sessionId) { - this.sendError(client, 'Missing sessionId'); + this.sendError(client, 'Missing sessionId', undefined, message); return; } if (!this.callbacks.selectSession) { logger.warn(`[Web] selectSessionCallback is not set!`, LOG_CONTEXT); - this.sendError(client, 'Session selection not configured'); + this.sendError(client, 'Session selection not configured', undefined, message); return; } @@ -405,17 +432,17 @@ export class WebSocketMessageHandler { } else { logger.warn(`Failed to select session ${sessionId} in desktop`, LOG_CONTEXT); } - this.send(client, { type: 'select_session_result', success, sessionId }); + this.send(client, { type: 'select_session_result', success, sessionId }, message); }) .catch((error) => { - this.sendError(client, `Failed to select session: ${error.message}`); + this.sendError(client, `Failed to select session: ${error.message}`, undefined, message); }); } /** * Handle get_sessions message - request updated sessions list */ - private handleGetSessions(client: WebClient): void { + private handleGetSessions(client: WebClient, message: WebClientMessage): void { if ( this.callbacks.getSessions && this.callbacks.getLiveSessionInfo && @@ -432,7 +459,7 @@ export class WebSocketMessageHandler { isLive: this.callbacks.isSessionLive!(s.id), }; }); - this.send(client, { type: 'sessions_list', sessions: sessionsWithLiveInfo }); + this.send(client, { type: 'sessions_list', sessions: sessionsWithLiveInfo }, message); } } @@ -678,22 +705,22 @@ export class WebSocketMessageHandler { ); if (!sessionId || !filePath) { - this.sendError(client, 'Missing sessionId or filePath'); + this.sendError(client, 'Missing sessionId or filePath', undefined, message); return; } if (!this.callbacks.openFileTab) { - this.sendError(client, 'File tab opening not configured'); + this.sendError(client, 'File tab opening not configured', undefined, message); return; } this.callbacks .openFileTab(sessionId, filePath) .then((success) => { - this.send(client, { type: 'open_file_tab_result', success, sessionId, filePath }); + this.send(client, { type: 'open_file_tab_result', success, sessionId, filePath }, message); }) .catch((error) => { - this.sendError(client, `Failed to open file tab: ${error.message}`); + this.sendError(client, `Failed to open file tab: ${error.message}`, undefined, message); }); } @@ -705,22 +732,22 @@ export class WebSocketMessageHandler { logger.info(`[Web] Received refresh_file_tree message: session=${sessionId}`, LOG_CONTEXT); if (!sessionId) { - this.sendError(client, 'Missing sessionId'); + this.sendError(client, 'Missing sessionId', undefined, message); return; } if (!this.callbacks.refreshFileTree) { - this.sendError(client, 'File tree refresh not configured'); + this.sendError(client, 'File tree refresh not configured', undefined, message); return; } this.callbacks .refreshFileTree(sessionId) .then((success) => { - this.send(client, { type: 'refresh_file_tree_result', success, sessionId }); + this.send(client, { type: 'refresh_file_tree_result', success, sessionId }, message); }) .catch((error) => { - this.sendError(client, `Failed to refresh file tree: ${error.message}`); + this.sendError(client, `Failed to refresh file tree: ${error.message}`, undefined, message); }); } @@ -732,22 +759,27 @@ export class WebSocketMessageHandler { logger.info(`[Web] Received refresh_auto_run_docs message: session=${sessionId}`, LOG_CONTEXT); if (!sessionId) { - this.sendError(client, 'Missing sessionId'); + this.sendError(client, 'Missing sessionId', undefined, message); return; } if (!this.callbacks.refreshAutoRunDocs) { - this.sendError(client, 'Auto Run docs refresh not configured'); + this.sendError(client, 'Auto Run docs refresh not configured', undefined, message); return; } this.callbacks .refreshAutoRunDocs(sessionId) .then((success) => { - this.send(client, { type: 'refresh_auto_run_docs_result', success, sessionId }); + this.send(client, { type: 'refresh_auto_run_docs_result', success, sessionId }, message); }) .catch((error) => { - this.sendError(client, `Failed to refresh Auto Run docs: ${error.message}`); + this.sendError( + client, + `Failed to refresh Auto Run docs: ${error.message}`, + undefined, + message + ); }); } @@ -760,29 +792,41 @@ export class WebSocketMessageHandler { logger.info(`[Web] Received configure_auto_run message: session=${sessionId}`, LOG_CONTEXT); if (!sessionId) { - this.send(client, { - type: 'configure_auto_run_result', - success: false, - error: 'Missing sessionId', - }); + this.send( + client, + { + type: 'configure_auto_run_result', + success: false, + error: 'Missing sessionId', + }, + message + ); return; } - if (!Array.isArray(documents) || documents.length === 0) { - this.send(client, { - type: 'configure_auto_run_result', - success: false, - error: 'Missing documents', - }); + if (!isValidConfigureAutoRunDocuments(documents)) { + this.send( + client, + { + type: 'configure_auto_run_result', + success: false, + error: 'Invalid documents payload', + }, + message + ); return; } if (!this.callbacks.configureAutoRun) { - this.send(client, { - type: 'configure_auto_run_result', - success: false, - error: 'Auto Run configuration not configured', - }); + this.send( + client, + { + type: 'configure_auto_run_result', + success: false, + error: 'Auto Run configuration not configured', + }, + message + ); return; } @@ -798,14 +842,18 @@ export class WebSocketMessageHandler { this.callbacks .configureAutoRun(sessionId, config) .then((result) => { - this.send(client, { type: 'configure_auto_run_result', ...result }); + this.send(client, { type: 'configure_auto_run_result', ...result }, message); }) .catch((error) => { - this.send(client, { - type: 'configure_auto_run_result', - success: false, - error: `Failed to configure Auto Run: ${error.message}`, - }); + this.send( + client, + { + type: 'configure_auto_run_result', + success: false, + error: `Failed to configure Auto Run: ${error.message}`, + }, + message + ); }); } diff --git a/src/main/web-server/types.ts b/src/main/web-server/types.ts index 9b18ad960d..4b5575ff0c 100644 --- a/src/main/web-server/types.ts +++ b/src/main/web-server/types.ts @@ -233,6 +233,7 @@ export interface WebClient { */ export interface WebClientMessage { type: string; + requestId?: string; sessionId?: string; tabId?: string; command?: string; diff --git a/src/main/web-server/web-server-factory.ts b/src/main/web-server/web-server-factory.ts index bc36816a33..00f38fae66 100644 --- a/src/main/web-server/web-server-factory.ts +++ b/src/main/web-server/web-server-factory.ts @@ -78,7 +78,7 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) { securityToken = randomUUID(); try { settingsStore.set('webAuthToken', securityToken); - } catch (e) { + } catch { // Persist failure is non-fatal — server starts with an ephemeral token logger.warn( 'Failed to persist new webAuthToken, URL will not survive restart', @@ -578,7 +578,7 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) { } return new Promise((resolve) => { - const responseChannel = `remote:configureAutoRun:response:${Date.now()}`; + const responseChannel = `remote:configureAutoRun:response:${randomUUID()}`; let resolved = false; const handleResponse = ( diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 1a108cb782..1d7c20f9c1 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1837,7 +1837,9 @@ function MaestroConsoleInner() { }); setActiveFocus('main'); } catch (error) { - console.error('[Remote] Failed to open file tab:', error); + captureException(error, { + extra: { context: 'handleRemoteOpenFileTab', sessionId, filePath }, + }); } }; @@ -1871,33 +1873,33 @@ function MaestroConsoleInner() { const sendResponse = (result: RemoteConfigureAutoRunResult) => { window.maestro.process.sendRemoteConfigureAutoRunResponse(responseChannel, result); }; - const session = sessionsRef.current.find((s) => s.id === sessionId); - if (!session) { - sendResponse({ success: false, error: 'Session not found' }); - return; - } + 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 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, }; - }); - const batchConfig: BatchRunConfig = { - documents, - prompt: config.prompt || '', - loopEnabled: config.loopEnabled || false, - maxLoops: - config.loopEnabled || config.maxLoops !== undefined ? (config.maxLoops ?? null) : null, - }; - if (config.saveAsPlaybook) { - try { + if (config.saveAsPlaybook) { const result = await window.maestro.playbooks.create(sessionId, { name: config.saveAsPlaybook, documents: documents.map((doc) => ({ @@ -1916,67 +1918,63 @@ function MaestroConsoleInner() { return; } sendResponse({ success: true, playbookId: result.playbook.id }); - } catch (error) { - sendResponse({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); + return; } - return; - } - if (!session.autoRunFolderPath) { - sendResponse({ success: false, error: 'Session has no Auto Run folder configured' }); - return; - } + if (!session.autoRunFolderPath) { + sendResponse({ success: false, error: 'Session has no Auto Run folder configured' }); + return; + } - if (config.launch) { - try { + if (config.launch) { await startBatchRunRef.current(sessionId, batchConfig, session.autoRunFolderPath); sendResponse({ success: true }); - } catch (error) { - sendResponse({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); + 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 || '' : ''; } - 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 + 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 + ) ); - selectedContent = result.success ? result.content || '' : ''; + 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), + }); } - - 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 }); }; window.addEventListener('maestro:openFileTab', handleRemoteOpenFileTab); diff --git a/src/shared/cli-server-discovery.ts b/src/shared/cli-server-discovery.ts index 0c5d1f1e22..839bc0d54b 100644 --- a/src/shared/cli-server-discovery.ts +++ b/src/shared/cli-server-discovery.ts @@ -48,13 +48,17 @@ function isValidCliServerInfo(data: unknown): data is CliServerInfo { const info = data as Partial; return ( typeof info.port === 'number' && - Number.isFinite(info.port) && + Number.isInteger(info.port) && + info.port > 0 && + info.port <= 65535 && typeof info.token === 'string' && info.token.length > 0 && typeof info.pid === 'number' && - Number.isFinite(info.pid) && + Number.isInteger(info.pid) && + info.pid > 0 && typeof info.startedAt === 'number' && - Number.isFinite(info.startedAt) + Number.isInteger(info.startedAt) && + info.startedAt >= 0 ); } @@ -67,10 +71,20 @@ export function writeCliServerInfo(info: CliServerInfo): void { const tmpPath = `${filePath}.tmp`; const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } else { + const stats = fs.statSync(dir); + if ((stats.mode & 0o077) !== 0) { + fs.chmodSync(dir, 0o700); + } } - 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); } catch (error) { console.error('[CLI Server Discovery] Failed to write discovery file:', error); }