Merge pull request #214 from pedramamini/code-refactor

perf: optimize hot path performance in main process
This commit is contained in:
Raza Rauf
2026-01-19 15:16:02 -06:00
committed by GitHub
5 changed files with 4896 additions and 3912 deletions

View File

@@ -0,0 +1,514 @@
/**
* @file performance-optimizations.test.ts
* @description Unit tests for performance optimizations in the main process.
*
* Tests cover:
* - Group chat session ID regex patterns
* - Buffer operations (O(1) append, correct join)
* - Debug logging conditional behavior
*/
import { describe, it, expect, beforeEach } from 'vitest';
// ============================================================================
// Regex Pattern Tests
// ============================================================================
// These patterns are used in hot paths (process data handlers) and are
// pre-compiled at module level for performance. We test them here to ensure
// they match the expected session ID formats.
describe('Group Chat Session ID Patterns', () => {
// Moderator session patterns
const REGEX_MODERATOR_SESSION = /^group-chat-(.+)-moderator-/;
const REGEX_MODERATOR_SESSION_TIMESTAMP = /^group-chat-(.+)-moderator-\d+$/;
// Participant session patterns
const REGEX_PARTICIPANT_UUID =
/^group-chat-(.+)-participant-(.+)-([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i;
const REGEX_PARTICIPANT_TIMESTAMP = /^group-chat-(.+)-participant-(.+)-(\d{13,})$/;
const REGEX_PARTICIPANT_FALLBACK = /^group-chat-(.+)-participant-([^-]+)-/;
// Web broadcast patterns
const REGEX_AI_SUFFIX = /-ai-[^-]+$/;
const REGEX_AI_TAB_ID = /-ai-([^-]+)$/;
describe('REGEX_MODERATOR_SESSION', () => {
it('should match moderator session IDs', () => {
const sessionId = 'group-chat-test-chat-123-moderator-1705678901234';
const match = sessionId.match(REGEX_MODERATOR_SESSION);
expect(match).not.toBeNull();
expect(match![1]).toBe('test-chat-123');
});
it('should match moderator synthesis session IDs', () => {
const sessionId = 'group-chat-my-chat-moderator-synthesis-1705678901234';
const match = sessionId.match(REGEX_MODERATOR_SESSION);
expect(match).not.toBeNull();
expect(match![1]).toBe('my-chat');
});
it('should not match participant session IDs', () => {
const sessionId = 'group-chat-test-chat-participant-Agent1-1705678901234';
const match = sessionId.match(REGEX_MODERATOR_SESSION);
expect(match).toBeNull();
});
it('should not match regular session IDs', () => {
const sessionId = 'session-123-ai-tab1';
const match = sessionId.match(REGEX_MODERATOR_SESSION);
expect(match).toBeNull();
});
});
describe('REGEX_MODERATOR_SESSION_TIMESTAMP', () => {
it('should match moderator session IDs with timestamp suffix', () => {
const sessionId = 'group-chat-test-chat-moderator-1705678901234';
const match = sessionId.match(REGEX_MODERATOR_SESSION_TIMESTAMP);
expect(match).not.toBeNull();
expect(match![1]).toBe('test-chat');
});
it('should not match moderator synthesis session IDs', () => {
const sessionId = 'group-chat-my-chat-moderator-synthesis-1705678901234';
const match = sessionId.match(REGEX_MODERATOR_SESSION_TIMESTAMP);
expect(match).toBeNull();
});
it('should not match session IDs without timestamp', () => {
const sessionId = 'group-chat-test-chat-moderator-';
const match = sessionId.match(REGEX_MODERATOR_SESSION_TIMESTAMP);
expect(match).toBeNull();
});
});
describe('REGEX_PARTICIPANT_UUID', () => {
it('should match participant session IDs with UUID suffix', () => {
const sessionId =
'group-chat-test-chat-participant-Agent1-a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const match = sessionId.match(REGEX_PARTICIPANT_UUID);
expect(match).not.toBeNull();
expect(match![1]).toBe('test-chat');
expect(match![2]).toBe('Agent1');
expect(match![3]).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
});
it('should match UUID case-insensitively', () => {
const sessionId =
'group-chat-test-chat-participant-Agent1-A1B2C3D4-E5F6-7890-ABCD-EF1234567890';
const match = sessionId.match(REGEX_PARTICIPANT_UUID);
expect(match).not.toBeNull();
});
it('should not match session IDs with invalid UUID', () => {
const sessionId = 'group-chat-test-chat-participant-Agent1-invalid-uuid';
const match = sessionId.match(REGEX_PARTICIPANT_UUID);
expect(match).toBeNull();
});
it('should handle participant names with hyphens', () => {
const sessionId =
'group-chat-test-chat-participant-My-Agent-Name-a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const match = sessionId.match(REGEX_PARTICIPANT_UUID);
expect(match).not.toBeNull();
expect(match![2]).toBe('My-Agent-Name');
});
});
describe('REGEX_PARTICIPANT_TIMESTAMP', () => {
it('should match participant session IDs with 13-digit timestamp', () => {
const sessionId = 'group-chat-test-chat-participant-Agent1-1705678901234';
const match = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP);
expect(match).not.toBeNull();
expect(match![1]).toBe('test-chat');
expect(match![2]).toBe('Agent1');
expect(match![3]).toBe('1705678901234');
});
it('should match timestamps with more than 13 digits', () => {
const sessionId = 'group-chat-test-chat-participant-Agent1-17056789012345';
const match = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP);
expect(match).not.toBeNull();
});
it('should not match timestamps with less than 13 digits', () => {
const sessionId = 'group-chat-test-chat-participant-Agent1-123456789012';
const match = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP);
expect(match).toBeNull();
});
it('should handle participant names with hyphens', () => {
const sessionId = 'group-chat-test-chat-participant-My-Agent-1705678901234';
const match = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP);
expect(match).not.toBeNull();
expect(match![2]).toBe('My-Agent');
});
});
describe('REGEX_PARTICIPANT_FALLBACK', () => {
it('should match participant session IDs with simple names', () => {
const sessionId = 'group-chat-test-chat-participant-Agent1-anything';
const match = sessionId.match(REGEX_PARTICIPANT_FALLBACK);
expect(match).not.toBeNull();
expect(match![1]).toBe('test-chat');
expect(match![2]).toBe('Agent1');
});
it('should stop at hyphen for participant name', () => {
const sessionId = 'group-chat-test-chat-participant-Agent-1-suffix';
const match = sessionId.match(REGEX_PARTICIPANT_FALLBACK);
expect(match).not.toBeNull();
expect(match![2]).toBe('Agent'); // Stops at first hyphen after participant name start
});
});
describe('REGEX_AI_SUFFIX', () => {
it('should match session IDs with -ai- suffix', () => {
const sessionId = 'session-123-ai-tab1';
expect(REGEX_AI_SUFFIX.test(sessionId)).toBe(true);
});
it('should remove -ai- suffix correctly', () => {
const sessionId = 'session-123-ai-tab1';
const baseSessionId = sessionId.replace(REGEX_AI_SUFFIX, '');
expect(baseSessionId).toBe('session-123');
});
it('should not match session IDs without -ai- suffix', () => {
const sessionId = 'session-123-terminal';
expect(REGEX_AI_SUFFIX.test(sessionId)).toBe(false);
});
});
describe('REGEX_AI_TAB_ID', () => {
it('should extract tab ID from session ID', () => {
const sessionId = 'session-123-ai-tab1';
const match = sessionId.match(REGEX_AI_TAB_ID);
expect(match).not.toBeNull();
expect(match![1]).toBe('tab1');
});
it('should handle complex tab IDs', () => {
const sessionId = 'session-123-ai-abc123xyz';
const match = sessionId.match(REGEX_AI_TAB_ID);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123xyz');
});
it('should return null for non-AI sessions', () => {
const sessionId = 'session-123-terminal';
const match = sessionId.match(REGEX_AI_TAB_ID);
expect(match).toBeNull();
});
});
});
// ============================================================================
// Buffer Operation Tests
// ============================================================================
// These tests verify the O(1) buffer implementation works correctly.
describe('Group Chat Output Buffer', () => {
// Simulate the buffer implementation
type BufferEntry = { chunks: string[]; totalLength: number };
let buffers: Map<string, BufferEntry>;
function appendToBuffer(sessionId: string, data: string): number {
let buffer = buffers.get(sessionId);
if (!buffer) {
buffer = { chunks: [], totalLength: 0 };
buffers.set(sessionId, buffer);
}
buffer.chunks.push(data);
buffer.totalLength += data.length;
return buffer.totalLength;
}
function getBufferedOutput(sessionId: string): string | undefined {
const buffer = buffers.get(sessionId);
if (!buffer || buffer.chunks.length === 0) return undefined;
return buffer.chunks.join('');
}
beforeEach(() => {
buffers = new Map();
});
describe('appendToBuffer', () => {
it('should create new buffer entry on first append', () => {
const length = appendToBuffer('session-1', 'hello');
expect(length).toBe(5);
expect(buffers.has('session-1')).toBe(true);
});
it('should append to existing buffer', () => {
appendToBuffer('session-1', 'hello');
const length = appendToBuffer('session-1', ' world');
expect(length).toBe(11); // 5 + 6
});
it('should track total length correctly across multiple appends', () => {
appendToBuffer('session-1', 'a'); // 1
appendToBuffer('session-1', 'bb'); // 3
appendToBuffer('session-1', 'ccc'); // 6
const length = appendToBuffer('session-1', 'dddd'); // 10
expect(length).toBe(10);
});
it('should maintain separate buffers for different sessions', () => {
appendToBuffer('session-1', 'hello');
appendToBuffer('session-2', 'world');
expect(buffers.get('session-1')?.totalLength).toBe(5);
expect(buffers.get('session-2')?.totalLength).toBe(5);
});
it('should handle empty strings', () => {
appendToBuffer('session-1', 'hello');
const length = appendToBuffer('session-1', '');
expect(length).toBe(5);
});
it('should handle large data chunks', () => {
const largeData = 'x'.repeat(100000);
const length = appendToBuffer('session-1', largeData);
expect(length).toBe(100000);
});
});
describe('getBufferedOutput', () => {
it('should return undefined for non-existent session', () => {
const output = getBufferedOutput('non-existent');
expect(output).toBeUndefined();
});
it('should return undefined for empty buffer', () => {
buffers.set('session-1', { chunks: [], totalLength: 0 });
const output = getBufferedOutput('session-1');
expect(output).toBeUndefined();
});
it('should join chunks correctly', () => {
appendToBuffer('session-1', 'hello');
appendToBuffer('session-1', ' ');
appendToBuffer('session-1', 'world');
const output = getBufferedOutput('session-1');
expect(output).toBe('hello world');
});
it('should preserve order of chunks', () => {
appendToBuffer('session-1', '1');
appendToBuffer('session-1', '2');
appendToBuffer('session-1', '3');
const output = getBufferedOutput('session-1');
expect(output).toBe('123');
});
it('should handle special characters', () => {
appendToBuffer('session-1', '{"type": "test"}');
appendToBuffer('session-1', '\n');
appendToBuffer('session-1', '{"type": "test2"}');
const output = getBufferedOutput('session-1');
expect(output).toBe('{"type": "test"}\n{"type": "test2"}');
});
});
describe('buffer cleanup', () => {
it('should allow deletion of buffer entries', () => {
appendToBuffer('session-1', 'data');
buffers.delete('session-1');
expect(buffers.has('session-1')).toBe(false);
});
it('should not affect other buffers on deletion', () => {
appendToBuffer('session-1', 'data1');
appendToBuffer('session-2', 'data2');
buffers.delete('session-1');
expect(buffers.has('session-1')).toBe(false);
expect(getBufferedOutput('session-2')).toBe('data2');
});
});
describe('performance characteristics', () => {
it('should maintain O(1) append by tracking totalLength incrementally', () => {
// This test verifies the implementation doesn't use reduce()
// by checking that totalLength matches sum of chunk lengths
const chunks = ['chunk1', 'chunk2', 'chunk3', 'chunk4', 'chunk5'];
for (const chunk of chunks) {
appendToBuffer('session-1', chunk);
}
const buffer = buffers.get('session-1')!;
const actualSum = buffer.chunks.reduce((sum, chunk) => sum + chunk.length, 0);
expect(buffer.totalLength).toBe(actualSum);
});
it('should handle many small appends efficiently', () => {
// Simulate streaming output with many small chunks
const startTime = Date.now();
for (let i = 0; i < 10000; i++) {
appendToBuffer('session-1', 'x');
}
const elapsed = Date.now() - startTime;
// Should complete in under 100ms for 10000 appends
// (generous threshold to avoid flaky tests)
expect(elapsed).toBeLessThan(100);
expect(buffers.get('session-1')?.totalLength).toBe(10000);
});
});
});
// ============================================================================
// Debug Logging Tests
// ============================================================================
// These tests verify the conditional debug logging behavior.
describe('Debug Logging', () => {
// Simulate the debugLog function
function createDebugLog(isEnabled: boolean) {
const logs: string[] = [];
function debugLog(prefix: string, message: string, ...args: unknown[]): void {
if (isEnabled) {
logs.push(`[${prefix}] ${message}`);
}
}
return { debugLog, logs };
}
it('should log when debug is enabled', () => {
const { debugLog, logs } = createDebugLog(true);
debugLog('GroupChat:Debug', 'Test message');
expect(logs).toHaveLength(1);
expect(logs[0]).toBe('[GroupChat:Debug] Test message');
});
it('should not log when debug is disabled', () => {
const { debugLog, logs } = createDebugLog(false);
debugLog('GroupChat:Debug', 'Test message');
expect(logs).toHaveLength(0);
});
it('should format message with prefix correctly', () => {
const { debugLog, logs } = createDebugLog(true);
debugLog('WebBroadcast', 'Broadcasting to session');
expect(logs[0]).toBe('[WebBroadcast] Broadcasting to session');
});
it('should handle multiple log calls', () => {
const { debugLog, logs } = createDebugLog(true);
debugLog('Test', 'Message 1');
debugLog('Test', 'Message 2');
debugLog('Test', 'Message 3');
expect(logs).toHaveLength(3);
});
});
// ============================================================================
// Session ID Parsing Tests
// ============================================================================
// These tests verify the parseParticipantSessionId logic.
describe('parseParticipantSessionId', () => {
// Simulate the parsing function
function parseParticipantSessionId(
sessionId: string
): { groupChatId: string; participantName: string } | null {
if (!sessionId.includes('-participant-')) {
return null;
}
const REGEX_PARTICIPANT_UUID =
/^group-chat-(.+)-participant-(.+)-([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i;
const REGEX_PARTICIPANT_TIMESTAMP = /^group-chat-(.+)-participant-(.+)-(\d{13,})$/;
const REGEX_PARTICIPANT_FALLBACK = /^group-chat-(.+)-participant-([^-]+)-/;
const uuidMatch = sessionId.match(REGEX_PARTICIPANT_UUID);
if (uuidMatch) {
return { groupChatId: uuidMatch[1], participantName: uuidMatch[2] };
}
const timestampMatch = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP);
if (timestampMatch) {
return { groupChatId: timestampMatch[1], participantName: timestampMatch[2] };
}
const fallbackMatch = sessionId.match(REGEX_PARTICIPANT_FALLBACK);
if (fallbackMatch) {
return { groupChatId: fallbackMatch[1], participantName: fallbackMatch[2] };
}
return null;
}
it('should return null for non-participant session IDs', () => {
expect(parseParticipantSessionId('session-123')).toBeNull();
expect(parseParticipantSessionId('group-chat-test-moderator-123')).toBeNull();
});
it('should parse UUID-based participant session IDs', () => {
const result = parseParticipantSessionId(
'group-chat-my-chat-participant-Claude-a1b2c3d4-e5f6-7890-abcd-ef1234567890'
);
expect(result).toEqual({
groupChatId: 'my-chat',
participantName: 'Claude',
});
});
it('should parse timestamp-based participant session IDs', () => {
const result = parseParticipantSessionId('group-chat-my-chat-participant-Claude-1705678901234');
expect(result).toEqual({
groupChatId: 'my-chat',
participantName: 'Claude',
});
});
it('should handle participant names with hyphens using UUID format', () => {
const result = parseParticipantSessionId(
'group-chat-my-chat-participant-Claude-Code-a1b2c3d4-e5f6-7890-abcd-ef1234567890'
);
expect(result).toEqual({
groupChatId: 'my-chat',
participantName: 'Claude-Code',
});
});
it('should handle group chat IDs with hyphens', () => {
const result = parseParticipantSessionId(
'group-chat-my-complex-chat-id-participant-Agent-1705678901234'
);
expect(result).toEqual({
groupChatId: 'my-complex-chat-id',
participantName: 'Agent',
});
});
it('should prefer UUID match over timestamp match', () => {
// This session ID could theoretically match both patterns
// but UUID should be tried first
const result = parseParticipantSessionId(
'group-chat-chat-participant-Agent-a1b2c3d4-e5f6-7890-abcd-ef1234567890'
);
expect(result).not.toBeNull();
expect(result?.participantName).toBe('Agent');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -22,190 +22,210 @@ import { WebServer } from '../../web-server';
* Interface for Maestro settings store
*/
export interface MaestroSettings {
activeThemeId: string;
llmProvider: string;
modelSlug: string;
apiKey: string;
shortcuts: Record<string, any>;
fontSize: number;
fontFamily: string;
customFonts: string[];
logLevel: 'debug' | 'info' | 'warn' | 'error';
defaultShell: string;
webAuthEnabled: boolean;
webAuthToken: string | null;
// SSH Remote configuration
sshRemotes: any[];
defaultSshRemoteId: string | null;
[key: string]: any;
activeThemeId: string;
llmProvider: string;
modelSlug: string;
apiKey: string;
shortcuts: Record<string, any>;
fontSize: number;
fontFamily: string;
customFonts: string[];
logLevel: 'debug' | 'info' | 'warn' | 'error';
defaultShell: string;
webAuthEnabled: boolean;
webAuthToken: string | null;
// Web interface custom port
webInterfaceUseCustomPort: boolean;
webInterfaceCustomPort: number;
// SSH Remote configuration
sshRemotes: any[];
defaultSshRemoteId: string | null;
// Unique installation identifier (generated once on first run)
installationId: string | null;
[key: string]: any;
}
/**
* Interface for sessions store
*/
export interface SessionsData {
sessions: any[];
sessions: any[];
}
/**
* Interface for groups store
*/
export interface GroupsData {
groups: any[];
groups: any[];
}
/**
* Dependencies required for persistence handlers
*/
export interface PersistenceHandlerDependencies {
settingsStore: Store<MaestroSettings>;
sessionsStore: Store<SessionsData>;
groupsStore: Store<GroupsData>;
getWebServer: () => WebServer | null;
settingsStore: Store<MaestroSettings>;
sessionsStore: Store<SessionsData>;
groupsStore: Store<GroupsData>;
getWebServer: () => WebServer | null;
}
/**
* Register all persistence-related IPC handlers.
*/
export function registerPersistenceHandlers(deps: PersistenceHandlerDependencies): void {
const { settingsStore, sessionsStore, groupsStore, getWebServer } = deps;
const { settingsStore, sessionsStore, groupsStore, getWebServer } = deps;
// Settings management
ipcMain.handle('settings:get', async (_, key: string) => {
const value = settingsStore.get(key);
logger.debug(`Settings read: ${key}`, 'Settings', { key, value });
return value;
});
// Settings management
ipcMain.handle('settings:get', async (_, key: string) => {
const value = settingsStore.get(key);
logger.debug(`Settings read: ${key}`, 'Settings', { key, value });
return value;
});
ipcMain.handle('settings:set', async (_, key: string, value: any) => {
settingsStore.set(key, value);
logger.info(`Settings updated: ${key}`, 'Settings', { key, value });
ipcMain.handle('settings:set', async (_, key: string, value: any) => {
settingsStore.set(key, value);
logger.info(`Settings updated: ${key}`, 'Settings', { key, value });
const webServer = getWebServer();
// Broadcast theme changes to connected web clients
if (key === 'activeThemeId' && webServer && webServer.getWebClientCount() > 0) {
const theme = getThemeById(value);
if (theme) {
webServer.broadcastThemeChange(theme);
logger.info(`Broadcasted theme change to web clients: ${value}`, 'WebServer');
}
}
const webServer = getWebServer();
// Broadcast theme changes to connected web clients
if (key === 'activeThemeId' && webServer && webServer.getWebClientCount() > 0) {
const theme = getThemeById(value);
if (theme) {
webServer.broadcastThemeChange(theme);
logger.info(`Broadcasted theme change to web clients: ${value}`, 'WebServer');
}
}
// Broadcast custom commands changes to connected web clients
if (key === 'customAICommands' && webServer && webServer.getWebClientCount() > 0) {
webServer.broadcastCustomCommands(value);
logger.info(`Broadcasted custom commands change to web clients: ${value.length} commands`, 'WebServer');
}
// Broadcast custom commands changes to connected web clients
if (key === 'customAICommands' && webServer && webServer.getWebClientCount() > 0) {
webServer.broadcastCustomCommands(value);
logger.info(
`Broadcasted custom commands change to web clients: ${value.length} commands`,
'WebServer'
);
}
return true;
});
return true;
});
ipcMain.handle('settings:getAll', async () => {
const settings = settingsStore.store;
logger.debug('All settings retrieved', 'Settings', { count: Object.keys(settings).length });
return settings;
});
ipcMain.handle('settings:getAll', async () => {
const settings = settingsStore.store;
logger.debug('All settings retrieved', 'Settings', { count: Object.keys(settings).length });
return settings;
});
// Sessions persistence
ipcMain.handle('sessions:getAll', async () => {
const sessions = sessionsStore.get('sessions', []);
console.log(`[sessions:getAll] Loaded ${sessions.length} sessions from store path: ${(sessionsStore as any).path}`);
return sessions;
});
// Sessions persistence
ipcMain.handle('sessions:getAll', async () => {
const sessions = sessionsStore.get('sessions', []);
console.log(
`[sessions:getAll] Loaded ${sessions.length} sessions from store path: ${(sessionsStore as any).path}`
);
return sessions;
});
ipcMain.handle('sessions:setAll', async (_, sessions: any[]) => {
// Get previous sessions to detect changes
const previousSessions = sessionsStore.get('sessions', []);
const previousSessionMap = new Map(previousSessions.map((s: any) => [s.id, s]));
const currentSessionMap = new Map(sessions.map((s: any) => [s.id, s]));
ipcMain.handle('sessions:setAll', async (_, sessions: any[]) => {
// Get previous sessions to detect changes
const previousSessions = sessionsStore.get('sessions', []);
const previousSessionMap = new Map(previousSessions.map((s: any) => [s.id, s]));
const currentSessionMap = new Map(sessions.map((s: any) => [s.id, s]));
// Log session lifecycle events at DEBUG level
for (const session of sessions) {
const prevSession = previousSessionMap.get(session.id);
if (!prevSession) {
// New session created
logger.debug('Session created', 'Sessions', { sessionId: session.id, name: session.name, toolType: session.toolType, cwd: session.cwd });
}
}
for (const prevSession of previousSessions) {
if (!currentSessionMap.has(prevSession.id)) {
// Session destroyed
logger.debug('Session destroyed', 'Sessions', { sessionId: prevSession.id, name: prevSession.name });
}
}
// Log session lifecycle events at DEBUG level
for (const session of sessions) {
const prevSession = previousSessionMap.get(session.id);
if (!prevSession) {
// New session created
logger.debug('Session created', 'Sessions', {
sessionId: session.id,
name: session.name,
toolType: session.toolType,
cwd: session.cwd,
});
}
}
for (const prevSession of previousSessions) {
if (!currentSessionMap.has(prevSession.id)) {
// Session destroyed
logger.debug('Session destroyed', 'Sessions', {
sessionId: prevSession.id,
name: prevSession.name,
});
}
}
const webServer = getWebServer();
// Detect and broadcast changes to web clients
if (webServer && webServer.getWebClientCount() > 0) {
// Check for state changes in existing sessions
for (const session of sessions) {
const prevSession = previousSessionMap.get(session.id);
if (prevSession) {
// Session exists - check if state or other tracked properties changed
if (prevSession.state !== session.state ||
prevSession.inputMode !== session.inputMode ||
prevSession.name !== session.name ||
prevSession.cwd !== session.cwd ||
JSON.stringify(prevSession.cliActivity) !== JSON.stringify(session.cliActivity)) {
webServer.broadcastSessionStateChange(session.id, session.state, {
name: session.name,
toolType: session.toolType,
inputMode: session.inputMode,
cwd: session.cwd,
cliActivity: session.cliActivity,
});
}
} else {
// New session added
webServer.broadcastSessionAdded({
id: session.id,
name: session.name,
toolType: session.toolType,
state: session.state,
inputMode: session.inputMode,
cwd: session.cwd,
groupId: session.groupId || null,
groupName: session.groupName || null,
groupEmoji: session.groupEmoji || null,
parentSessionId: session.parentSessionId || null,
worktreeBranch: session.worktreeBranch || null,
});
}
}
const webServer = getWebServer();
// Detect and broadcast changes to web clients
if (webServer && webServer.getWebClientCount() > 0) {
// Check for state changes in existing sessions
for (const session of sessions) {
const prevSession = previousSessionMap.get(session.id);
if (prevSession) {
// Session exists - check if state or other tracked properties changed
if (
prevSession.state !== session.state ||
prevSession.inputMode !== session.inputMode ||
prevSession.name !== session.name ||
prevSession.cwd !== session.cwd ||
JSON.stringify(prevSession.cliActivity) !== JSON.stringify(session.cliActivity)
) {
webServer.broadcastSessionStateChange(session.id, session.state, {
name: session.name,
toolType: session.toolType,
inputMode: session.inputMode,
cwd: session.cwd,
cliActivity: session.cliActivity,
});
}
} else {
// New session added
webServer.broadcastSessionAdded({
id: session.id,
name: session.name,
toolType: session.toolType,
state: session.state,
inputMode: session.inputMode,
cwd: session.cwd,
groupId: session.groupId || null,
groupName: session.groupName || null,
groupEmoji: session.groupEmoji || null,
parentSessionId: session.parentSessionId || null,
worktreeBranch: session.worktreeBranch || null,
});
}
}
// Check for removed sessions
for (const prevSession of previousSessions) {
if (!currentSessionMap.has(prevSession.id)) {
webServer.broadcastSessionRemoved(prevSession.id);
}
}
}
// Check for removed sessions
for (const prevSession of previousSessions) {
if (!currentSessionMap.has(prevSession.id)) {
webServer.broadcastSessionRemoved(prevSession.id);
}
}
}
sessionsStore.set('sessions', sessions);
sessionsStore.set('sessions', sessions);
return true;
});
return true;
});
// Groups persistence
ipcMain.handle('groups:getAll', async () => {
return groupsStore.get('groups', []);
});
// Groups persistence
ipcMain.handle('groups:getAll', async () => {
return groupsStore.get('groups', []);
});
ipcMain.handle('groups:setAll', async (_, groups: any[]) => {
groupsStore.set('groups', groups);
return true;
});
ipcMain.handle('groups:setAll', async (_, groups: any[]) => {
groupsStore.set('groups', groups);
return true;
});
// CLI activity (for detecting when CLI is running playbooks)
ipcMain.handle('cli:getActivity', async () => {
try {
const cliActivityPath = path.join(app.getPath('userData'), 'cli-activity.json');
const content = await fs.readFile(cliActivityPath, 'utf-8');
const data = JSON.parse(content);
return data.activities || [];
} catch {
// File doesn't exist or is invalid - return empty array
return [];
}
});
// CLI activity (for detecting when CLI is running playbooks)
ipcMain.handle('cli:getActivity', async () => {
try {
const cliActivityPath = path.join(app.getPath('userData'), 'cli-activity.json');
const content = await fs.readFile(cliActivityPath, 'utf-8');
const data = JSON.parse(content);
return data.activities || [];
} catch {
// File doesn't exist or is invalid - return empty array
return [];
}
});
}

View File

@@ -4,17 +4,22 @@ import * as os from 'os';
import { ProcessManager } from '../../process-manager';
import { AgentDetector } from '../../agent-detector';
import { logger } from '../../utils/logger';
import { buildAgentArgs, applyAgentConfigOverrides, getContextWindowValue } from '../../utils/agent-args';
import {
withIpcErrorLogging,
requireProcessManager,
requireDependency,
CreateHandlerOptions,
buildAgentArgs,
applyAgentConfigOverrides,
getContextWindowValue,
} from '../../utils/agent-args';
import {
withIpcErrorLogging,
requireProcessManager,
requireDependency,
CreateHandlerOptions,
} from '../../utils/ipcHandler';
import { getSshRemoteConfig, createSshRemoteStoreAdapter } from '../../utils/ssh-remote-resolver';
import { buildSshCommand } from '../../utils/ssh-command-builder';
import type { SshRemoteConfig } from '../../../shared/types';
import { powerManager } from '../../power-manager';
import { MaestroSettings } from './persistence';
const LOG_CONTEXT = '[ProcessManager]';
@@ -22,44 +27,30 @@ const LOG_CONTEXT = '[ProcessManager]';
* Helper to create handler options with consistent context
*/
const handlerOpts = (
operation: string,
extra?: Partial<CreateHandlerOptions>
operation: string,
extra?: Partial<CreateHandlerOptions>
): Pick<CreateHandlerOptions, 'context' | 'operation'> => ({
context: LOG_CONTEXT,
operation,
...extra,
context: LOG_CONTEXT,
operation,
...extra,
});
/**
* Interface for agent configuration store data
*/
interface AgentConfigsData {
configs: Record<string, Record<string, any>>;
}
/**
* Interface for Maestro settings store
*/
interface MaestroSettings {
defaultShell: string;
customShellPath?: string; // Custom path to shell binary (overrides auto-detected path)
shellArgs?: string; // Additional CLI arguments for shell sessions
shellEnvVars?: Record<string, string>; // Environment variables for shell sessions
// SSH remote execution
sshRemotes: SshRemoteConfig[];
defaultSshRemoteId: string | null;
[key: string]: any;
configs: Record<string, Record<string, any>>;
}
/**
* Dependencies required for process handler registration
*/
export interface ProcessHandlerDependencies {
getProcessManager: () => ProcessManager | null;
getAgentDetector: () => AgentDetector | null;
agentConfigsStore: Store<AgentConfigsData>;
settingsStore: Store<MaestroSettings>;
getMainWindow: () => BrowserWindow | null;
getProcessManager: () => ProcessManager | null;
getAgentDetector: () => AgentDetector | null;
agentConfigsStore: Store<AgentConfigsData>;
settingsStore: Store<MaestroSettings>;
getMainWindow: () => BrowserWindow | null;
}
/**
@@ -75,435 +66,478 @@ export interface ProcessHandlerDependencies {
* - runCommand: Execute a single command and capture output
*/
export function registerProcessHandlers(deps: ProcessHandlerDependencies): void {
const { getProcessManager, getAgentDetector, agentConfigsStore, settingsStore, getMainWindow } = deps;
const { getProcessManager, getAgentDetector, agentConfigsStore, settingsStore, getMainWindow } =
deps;
// Spawn a new process for a session
// Supports agent-specific argument builders for batch mode, JSON output, resume, read-only mode, YOLO mode
ipcMain.handle(
'process:spawn',
withIpcErrorLogging(handlerOpts('spawn'), async (config: {
sessionId: string;
toolType: string;
cwd: string;
command: string;
args: string[];
prompt?: string;
shell?: string;
images?: string[]; // Base64 data URLs for images
// Agent-specific spawn options (used to build args via agent config)
agentSessionId?: string; // For session resume
readOnlyMode?: boolean; // For read-only/plan mode
modelId?: string; // For model selection
yoloMode?: boolean; // For YOLO/full-access mode (bypasses confirmations)
// Per-session overrides (take precedence over agent-level config)
sessionCustomPath?: string; // Session-specific custom path
sessionCustomArgs?: string; // Session-specific custom args
sessionCustomEnvVars?: Record<string, string>; // Session-specific env vars
sessionCustomModel?: string; // Session-specific model selection
sessionCustomContextWindow?: number; // Session-specific context window size
// Per-session SSH remote config (takes precedence over agent-level SSH config)
sessionSshRemoteConfig?: {
enabled: boolean;
remoteId: string | null;
workingDirOverride?: string;
};
// Stats tracking options
querySource?: 'user' | 'auto'; // Whether this query is user-initiated or from Auto Run
tabId?: string; // Tab ID for multi-tab tracking
}) => {
const processManager = requireProcessManager(getProcessManager);
const agentDetector = requireDependency(getAgentDetector, 'Agent detector');
// Spawn a new process for a session
// Supports agent-specific argument builders for batch mode, JSON output, resume, read-only mode, YOLO mode
ipcMain.handle(
'process:spawn',
withIpcErrorLogging(
handlerOpts('spawn'),
async (config: {
sessionId: string;
toolType: string;
cwd: string;
command: string;
args: string[];
prompt?: string;
shell?: string;
images?: string[]; // Base64 data URLs for images
// Agent-specific spawn options (used to build args via agent config)
agentSessionId?: string; // For session resume
readOnlyMode?: boolean; // For read-only/plan mode
modelId?: string; // For model selection
yoloMode?: boolean; // For YOLO/full-access mode (bypasses confirmations)
// Per-session overrides (take precedence over agent-level config)
sessionCustomPath?: string; // Session-specific custom path
sessionCustomArgs?: string; // Session-specific custom args
sessionCustomEnvVars?: Record<string, string>; // Session-specific env vars
sessionCustomModel?: string; // Session-specific model selection
sessionCustomContextWindow?: number; // Session-specific context window size
// Per-session SSH remote config (takes precedence over agent-level SSH config)
sessionSshRemoteConfig?: {
enabled: boolean;
remoteId: string | null;
workingDirOverride?: string;
};
// Stats tracking options
querySource?: 'user' | 'auto'; // Whether this query is user-initiated or from Auto Run
tabId?: string; // Tab ID for multi-tab tracking
}) => {
const processManager = requireProcessManager(getProcessManager);
const agentDetector = requireDependency(getAgentDetector, 'Agent detector');
// Get agent definition to access config options and argument builders
const agent = await agentDetector.getAgent(config.toolType);
// Use INFO level on Windows for better visibility in logs
const isWindows = process.platform === 'win32';
const logFn = isWindows ? logger.info.bind(logger) : logger.debug.bind(logger);
logFn(`Spawn config received`, LOG_CONTEXT, {
platform: process.platform,
configToolType: config.toolType,
configCommand: config.command,
agentId: agent?.id,
agentCommand: agent?.command,
agentPath: agent?.path,
agentPathExtension: agent?.path ? require('path').extname(agent.path) : 'none',
hasAgentSessionId: !!config.agentSessionId,
hasPrompt: !!config.prompt,
promptLength: config.prompt?.length,
// On Windows, show prompt preview to help debug truncation issues
promptPreview: config.prompt && isWindows ? {
first50: config.prompt.substring(0, 50),
last50: config.prompt.substring(Math.max(0, config.prompt.length - 50)),
containsHash: config.prompt.includes('#'),
containsNewline: config.prompt.includes('\n'),
} : undefined,
});
let finalArgs = buildAgentArgs(agent, {
baseArgs: config.args,
prompt: config.prompt,
cwd: config.cwd,
readOnlyMode: config.readOnlyMode,
modelId: config.modelId,
yoloMode: config.yoloMode,
agentSessionId: config.agentSessionId,
});
// Get agent definition to access config options and argument builders
const agent = await agentDetector.getAgent(config.toolType);
// Use INFO level on Windows for better visibility in logs
const isWindows = process.platform === 'win32';
const logFn = isWindows ? logger.info.bind(logger) : logger.debug.bind(logger);
logFn(`Spawn config received`, LOG_CONTEXT, {
platform: process.platform,
configToolType: config.toolType,
configCommand: config.command,
agentId: agent?.id,
agentCommand: agent?.command,
agentPath: agent?.path,
agentPathExtension: agent?.path ? require('path').extname(agent.path) : 'none',
hasAgentSessionId: !!config.agentSessionId,
hasPrompt: !!config.prompt,
promptLength: config.prompt?.length,
// On Windows, show prompt preview to help debug truncation issues
promptPreview:
config.prompt && isWindows
? {
first50: config.prompt.substring(0, 50),
last50: config.prompt.substring(Math.max(0, config.prompt.length - 50)),
containsHash: config.prompt.includes('#'),
containsNewline: config.prompt.includes('\n'),
}
: undefined,
});
let finalArgs = buildAgentArgs(agent, {
baseArgs: config.args,
prompt: config.prompt,
cwd: config.cwd,
readOnlyMode: config.readOnlyMode,
modelId: config.modelId,
yoloMode: config.yoloMode,
agentSessionId: config.agentSessionId,
});
// ========================================================================
// Apply agent config options and session overrides
// Session-level overrides take precedence over agent-level config
// ========================================================================
const allConfigs = agentConfigsStore.get('configs', {});
const agentConfigValues = allConfigs[config.toolType] || {};
const configResolution = applyAgentConfigOverrides(agent, finalArgs, {
agentConfigValues,
sessionCustomModel: config.sessionCustomModel,
sessionCustomArgs: config.sessionCustomArgs,
sessionCustomEnvVars: config.sessionCustomEnvVars,
});
finalArgs = configResolution.args;
// ========================================================================
// Apply agent config options and session overrides
// Session-level overrides take precedence over agent-level config
// ========================================================================
const allConfigs = agentConfigsStore.get('configs', {});
const agentConfigValues = allConfigs[config.toolType] || {};
const configResolution = applyAgentConfigOverrides(agent, finalArgs, {
agentConfigValues,
sessionCustomModel: config.sessionCustomModel,
sessionCustomArgs: config.sessionCustomArgs,
sessionCustomEnvVars: config.sessionCustomEnvVars,
});
finalArgs = configResolution.args;
if (configResolution.modelSource === 'session' && config.sessionCustomModel) {
logger.debug(`Using session-level model for ${config.toolType}`, LOG_CONTEXT, { model: config.sessionCustomModel });
}
if (configResolution.modelSource === 'session' && config.sessionCustomModel) {
logger.debug(`Using session-level model for ${config.toolType}`, LOG_CONTEXT, {
model: config.sessionCustomModel,
});
}
if (configResolution.customArgsSource !== 'none') {
logger.debug(`Appending custom args for ${config.toolType} (${configResolution.customArgsSource}-level)`, LOG_CONTEXT);
}
if (configResolution.customArgsSource !== 'none') {
logger.debug(
`Appending custom args for ${config.toolType} (${configResolution.customArgsSource}-level)`,
LOG_CONTEXT
);
}
const effectiveCustomEnvVars = configResolution.effectiveCustomEnvVars;
if (configResolution.customEnvSource !== 'none' && effectiveCustomEnvVars) {
logger.debug(`Custom env vars configured for ${config.toolType} (${configResolution.customEnvSource}-level)`, LOG_CONTEXT, { keys: Object.keys(effectiveCustomEnvVars) });
}
const effectiveCustomEnvVars = configResolution.effectiveCustomEnvVars;
if (configResolution.customEnvSource !== 'none' && effectiveCustomEnvVars) {
logger.debug(
`Custom env vars configured for ${config.toolType} (${configResolution.customEnvSource}-level)`,
LOG_CONTEXT,
{ keys: Object.keys(effectiveCustomEnvVars) }
);
}
// If no shell is specified and this is a terminal session, use the default shell from settings
// For terminal sessions, we also load custom shell path, args, and env vars
let shellToUse = config.shell || (config.toolType === 'terminal' ? settingsStore.get('defaultShell', 'zsh') : undefined);
let shellArgsStr: string | undefined;
let shellEnvVars: Record<string, string> | undefined;
// If no shell is specified and this is a terminal session, use the default shell from settings
// For terminal sessions, we also load custom shell path, args, and env vars
let shellToUse =
config.shell ||
(config.toolType === 'terminal' ? settingsStore.get('defaultShell', 'zsh') : undefined);
let shellArgsStr: string | undefined;
let shellEnvVars: Record<string, string> | undefined;
if (config.toolType === 'terminal') {
// Custom shell path overrides the detected/selected shell path
const customShellPath = settingsStore.get('customShellPath', '');
if (customShellPath && customShellPath.trim()) {
shellToUse = customShellPath.trim();
logger.debug('Using custom shell path for terminal', LOG_CONTEXT, { customShellPath });
}
// Load additional shell args and env vars
shellArgsStr = settingsStore.get('shellArgs', '');
shellEnvVars = settingsStore.get('shellEnvVars', {}) as Record<string, string>;
}
if (config.toolType === 'terminal') {
// Custom shell path overrides the detected/selected shell path
const customShellPath = settingsStore.get('customShellPath', '');
if (customShellPath && customShellPath.trim()) {
shellToUse = customShellPath.trim();
logger.debug('Using custom shell path for terminal', LOG_CONTEXT, { customShellPath });
}
// Load additional shell args and env vars
shellArgsStr = settingsStore.get('shellArgs', '');
shellEnvVars = settingsStore.get('shellEnvVars', {}) as Record<string, string>;
}
// Extract session ID from args for logging (supports both --resume and --session flags)
const resumeArgIndex = finalArgs.indexOf('--resume');
const sessionArgIndex = finalArgs.indexOf('--session');
const agentSessionId = resumeArgIndex !== -1
? finalArgs[resumeArgIndex + 1]
: sessionArgIndex !== -1
? finalArgs[sessionArgIndex + 1]
: config.agentSessionId;
// Extract session ID from args for logging (supports both --resume and --session flags)
const resumeArgIndex = finalArgs.indexOf('--resume');
const sessionArgIndex = finalArgs.indexOf('--session');
const agentSessionId =
resumeArgIndex !== -1
? finalArgs[resumeArgIndex + 1]
: sessionArgIndex !== -1
? finalArgs[sessionArgIndex + 1]
: config.agentSessionId;
logger.info(`Spawning process: ${config.command}`, LOG_CONTEXT, {
sessionId: config.sessionId,
toolType: config.toolType,
cwd: config.cwd,
command: config.command,
fullCommand: `${config.command} ${finalArgs.join(' ')}`,
args: finalArgs,
requiresPty: agent?.requiresPty || false,
shell: shellToUse,
...(agentSessionId && { agentSessionId }),
...(config.readOnlyMode && { readOnlyMode: true }),
...(config.yoloMode && { yoloMode: true }),
...(config.modelId && { modelId: config.modelId }),
...(config.prompt && { prompt: config.prompt.length > 500 ? config.prompt.substring(0, 500) + '...' : config.prompt })
});
logger.info(`Spawning process: ${config.command}`, LOG_CONTEXT, {
sessionId: config.sessionId,
toolType: config.toolType,
cwd: config.cwd,
command: config.command,
fullCommand: `${config.command} ${finalArgs.join(' ')}`,
args: finalArgs,
requiresPty: agent?.requiresPty || false,
shell: shellToUse,
...(agentSessionId && { agentSessionId }),
...(config.readOnlyMode && { readOnlyMode: true }),
...(config.yoloMode && { yoloMode: true }),
...(config.modelId && { modelId: config.modelId }),
...(config.prompt && {
prompt:
config.prompt.length > 500 ? config.prompt.substring(0, 500) + '...' : config.prompt,
}),
});
// Get contextWindow: session-level override takes priority over agent-level config
// Falls back to the agent's configOptions default (e.g., 400000 for Codex, 128000 for OpenCode)
const contextWindow = getContextWindowValue(agent, agentConfigValues, config.sessionCustomContextWindow);
// Get contextWindow: session-level override takes priority over agent-level config
// Falls back to the agent's configOptions default (e.g., 400000 for Codex, 128000 for OpenCode)
const contextWindow = getContextWindowValue(
agent,
agentConfigValues,
config.sessionCustomContextWindow
);
// ========================================================================
// Command Resolution: Apply session-level custom path override if set
// This allows users to override the detected agent path per-session
// ========================================================================
let commandToSpawn = config.sessionCustomPath || config.command;
let argsToSpawn = finalArgs;
// ========================================================================
// Command Resolution: Apply session-level custom path override if set
// This allows users to override the detected agent path per-session
// ========================================================================
let commandToSpawn = config.sessionCustomPath || config.command;
let argsToSpawn = finalArgs;
if (config.sessionCustomPath) {
logger.debug(`Using session-level custom path for ${config.toolType}`, LOG_CONTEXT, {
customPath: config.sessionCustomPath,
originalCommand: config.command,
});
}
if (config.sessionCustomPath) {
logger.debug(`Using session-level custom path for ${config.toolType}`, LOG_CONTEXT, {
customPath: config.sessionCustomPath,
originalCommand: config.command,
});
}
// ========================================================================
// SSH Remote Execution: Detect and wrap command for remote execution
// Terminal sessions are always local (they need PTY for shell interaction)
// ========================================================================
let sshRemoteUsed: SshRemoteConfig | null = null;
// ========================================================================
// SSH Remote Execution: Detect and wrap command for remote execution
// Terminal sessions are always local (they need PTY for shell interaction)
// ========================================================================
let sshRemoteUsed: SshRemoteConfig | null = null;
// Only consider SSH remote for non-terminal AI agent sessions
// SSH is session-level ONLY - no agent-level or global defaults
if (config.toolType !== 'terminal' && config.sessionSshRemoteConfig) {
// Session-level SSH config provided - resolve and use it
logger.debug(`Using session-level SSH config`, LOG_CONTEXT, {
sessionId: config.sessionId,
enabled: config.sessionSshRemoteConfig.enabled,
remoteId: config.sessionSshRemoteConfig.remoteId,
});
// Only consider SSH remote for non-terminal AI agent sessions
// SSH is session-level ONLY - no agent-level or global defaults
if (config.toolType !== 'terminal' && config.sessionSshRemoteConfig) {
// Session-level SSH config provided - resolve and use it
logger.debug(`Using session-level SSH config`, LOG_CONTEXT, {
sessionId: config.sessionId,
enabled: config.sessionSshRemoteConfig.enabled,
remoteId: config.sessionSshRemoteConfig.remoteId,
});
// Resolve effective SSH remote configuration
const sshStoreAdapter = createSshRemoteStoreAdapter(settingsStore);
const sshResult = getSshRemoteConfig(sshStoreAdapter, {
sessionSshConfig: config.sessionSshRemoteConfig,
});
// Resolve effective SSH remote configuration
const sshStoreAdapter = createSshRemoteStoreAdapter(settingsStore);
const sshResult = getSshRemoteConfig(sshStoreAdapter, {
sessionSshConfig: config.sessionSshRemoteConfig,
});
if (sshResult.config) {
// SSH remote is configured - wrap the command for remote execution
sshRemoteUsed = sshResult.config;
if (sshResult.config) {
// SSH remote is configured - wrap the command for remote execution
sshRemoteUsed = sshResult.config;
// For SSH execution, we need to include the prompt in the args here
// because ProcessManager.spawn() won't add it (we pass prompt: undefined for SSH)
// Use promptArgs if available (e.g., OpenCode -p), otherwise use positional arg
let sshArgs = finalArgs;
if (config.prompt) {
if (agent?.promptArgs) {
sshArgs = [...finalArgs, ...agent.promptArgs(config.prompt)];
} else if (agent?.noPromptSeparator) {
sshArgs = [...finalArgs, config.prompt];
} else {
sshArgs = [...finalArgs, '--', config.prompt];
}
}
// For SSH execution, we need to include the prompt in the args here
// because ProcessManager.spawn() won't add it (we pass prompt: undefined for SSH)
// Use promptArgs if available (e.g., OpenCode -p), otherwise use positional arg
let sshArgs = finalArgs;
if (config.prompt) {
if (agent?.promptArgs) {
sshArgs = [...finalArgs, ...agent.promptArgs(config.prompt)];
} else if (agent?.noPromptSeparator) {
sshArgs = [...finalArgs, config.prompt];
} else {
sshArgs = [...finalArgs, '--', config.prompt];
}
}
// Build the SSH command that wraps the agent execution
//
// Determine the command to run on the remote host:
// 1. If user set a session-specific custom path, use that (they configured it for the remote)
// 2. Otherwise, use the agent's binaryName (e.g., 'codex', 'claude') and let
// the remote shell's PATH resolve it. This avoids using local paths like
// '/opt/homebrew/bin/codex' which don't exist on the remote host.
const remoteCommand = config.sessionCustomPath || agent?.binaryName || config.command;
const sshCommand = await buildSshCommand(sshResult.config, {
command: remoteCommand,
args: sshArgs,
// Use the cwd from config - this is the project directory on the remote
cwd: config.cwd,
// Pass custom environment variables to the remote command
env: effectiveCustomEnvVars,
});
// Build the SSH command that wraps the agent execution
//
// Determine the command to run on the remote host:
// 1. If user set a session-specific custom path, use that (they configured it for the remote)
// 2. Otherwise, use the agent's binaryName (e.g., 'codex', 'claude') and let
// the remote shell's PATH resolve it. This avoids using local paths like
// '/opt/homebrew/bin/codex' which don't exist on the remote host.
const remoteCommand = config.sessionCustomPath || agent?.binaryName || config.command;
const sshCommand = await buildSshCommand(sshResult.config, {
command: remoteCommand,
args: sshArgs,
// Use the cwd from config - this is the project directory on the remote
cwd: config.cwd,
// Pass custom environment variables to the remote command
env: effectiveCustomEnvVars,
});
commandToSpawn = sshCommand.command;
argsToSpawn = sshCommand.args;
commandToSpawn = sshCommand.command;
argsToSpawn = sshCommand.args;
logger.info(`SSH remote execution configured`, LOG_CONTEXT, {
sessionId: config.sessionId,
toolType: config.toolType,
remoteName: sshResult.config.name,
remoteHost: sshResult.config.host,
source: sshResult.source,
localCommand: config.command,
remoteCommand: remoteCommand,
customPath: config.sessionCustomPath || null,
hasCustomEnvVars: !!effectiveCustomEnvVars && Object.keys(effectiveCustomEnvVars).length > 0,
sshCommand: `${sshCommand.command} ${sshCommand.args.join(' ')}`,
});
}
}
logger.info(`SSH remote execution configured`, LOG_CONTEXT, {
sessionId: config.sessionId,
toolType: config.toolType,
remoteName: sshResult.config.name,
remoteHost: sshResult.config.host,
source: sshResult.source,
localCommand: config.command,
remoteCommand: remoteCommand,
customPath: config.sessionCustomPath || null,
hasCustomEnvVars:
!!effectiveCustomEnvVars && Object.keys(effectiveCustomEnvVars).length > 0,
sshCommand: `${sshCommand.command} ${sshCommand.args.join(' ')}`,
});
}
}
const result = processManager.spawn({
...config,
command: commandToSpawn,
args: argsToSpawn,
// When using SSH, use user's home directory as local cwd
// The remote working directory is embedded in the SSH command itself
// This fixes ENOENT errors when session.cwd is a remote-only path
cwd: sshRemoteUsed ? os.homedir() : config.cwd,
// When using SSH, disable PTY (SSH provides its own terminal handling)
// and env vars are passed via the remote command string
requiresPty: sshRemoteUsed ? false : agent?.requiresPty,
// When using SSH, the prompt was already added to sshArgs above before
// building the SSH command, so don't let ProcessManager add it again
prompt: sshRemoteUsed ? undefined : config.prompt,
shell: shellToUse,
shellArgs: shellArgsStr, // Shell-specific CLI args (for terminal sessions)
shellEnvVars: shellEnvVars, // Shell-specific env vars (for terminal sessions)
contextWindow, // Pass configured context window to process manager
// When using SSH, env vars are passed in the remote command string, not locally
customEnvVars: sshRemoteUsed ? undefined : effectiveCustomEnvVars,
imageArgs: agent?.imageArgs, // Function to build image CLI args (for Codex, OpenCode)
promptArgs: agent?.promptArgs, // Function to build prompt args (e.g., ['-p', prompt] for OpenCode)
noPromptSeparator: agent?.noPromptSeparator, // Some agents don't support '--' before prompt
// Stats tracking: use cwd as projectPath if not explicitly provided
projectPath: config.cwd,
// SSH remote context (for SSH-specific error messages)
sshRemoteId: sshRemoteUsed?.id,
sshRemoteHost: sshRemoteUsed?.host,
});
const result = processManager.spawn({
...config,
command: commandToSpawn,
args: argsToSpawn,
// When using SSH, use user's home directory as local cwd
// The remote working directory is embedded in the SSH command itself
// This fixes ENOENT errors when session.cwd is a remote-only path
cwd: sshRemoteUsed ? os.homedir() : config.cwd,
// When using SSH, disable PTY (SSH provides its own terminal handling)
// and env vars are passed via the remote command string
requiresPty: sshRemoteUsed ? false : agent?.requiresPty,
// When using SSH, the prompt was already added to sshArgs above before
// building the SSH command, so don't let ProcessManager add it again
prompt: sshRemoteUsed ? undefined : config.prompt,
shell: shellToUse,
shellArgs: shellArgsStr, // Shell-specific CLI args (for terminal sessions)
shellEnvVars: shellEnvVars, // Shell-specific env vars (for terminal sessions)
contextWindow, // Pass configured context window to process manager
// When using SSH, env vars are passed in the remote command string, not locally
customEnvVars: sshRemoteUsed ? undefined : effectiveCustomEnvVars,
imageArgs: agent?.imageArgs, // Function to build image CLI args (for Codex, OpenCode)
promptArgs: agent?.promptArgs, // Function to build prompt args (e.g., ['-p', prompt] for OpenCode)
noPromptSeparator: agent?.noPromptSeparator, // Some agents don't support '--' before prompt
// Stats tracking: use cwd as projectPath if not explicitly provided
projectPath: config.cwd,
// SSH remote context (for SSH-specific error messages)
sshRemoteId: sshRemoteUsed?.id,
sshRemoteHost: sshRemoteUsed?.host,
});
logger.info(`Process spawned successfully`, LOG_CONTEXT, {
sessionId: config.sessionId,
pid: result.pid,
...(sshRemoteUsed && { sshRemoteId: sshRemoteUsed.id, sshRemoteName: sshRemoteUsed.name })
});
logger.info(`Process spawned successfully`, LOG_CONTEXT, {
sessionId: config.sessionId,
pid: result.pid,
...(sshRemoteUsed && {
sshRemoteId: sshRemoteUsed.id,
sshRemoteName: sshRemoteUsed.name,
}),
});
// Add power block reason for AI sessions (not terminals)
// This prevents system sleep while AI is processing
if (config.toolType !== 'terminal') {
powerManager.addBlockReason(`session:${config.sessionId}`);
}
// Add power block reason for AI sessions (not terminals)
// This prevents system sleep while AI is processing
if (config.toolType !== 'terminal') {
powerManager.addBlockReason(`session:${config.sessionId}`);
}
// Emit SSH remote status event for renderer to update session state
// This is emitted for all spawns (sshRemote will be null for local execution)
const mainWindow = getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
const sshRemoteInfo = sshRemoteUsed ? {
id: sshRemoteUsed.id,
name: sshRemoteUsed.name,
host: sshRemoteUsed.host,
} : null;
mainWindow.webContents.send('process:ssh-remote', config.sessionId, sshRemoteInfo);
}
// Emit SSH remote status event for renderer to update session state
// This is emitted for all spawns (sshRemote will be null for local execution)
const mainWindow = getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
const sshRemoteInfo = sshRemoteUsed
? {
id: sshRemoteUsed.id,
name: sshRemoteUsed.name,
host: sshRemoteUsed.host,
}
: null;
mainWindow.webContents.send('process:ssh-remote', config.sessionId, sshRemoteInfo);
}
// Return spawn result with SSH remote info if used
return {
...result,
sshRemote: sshRemoteUsed ? {
id: sshRemoteUsed.id,
name: sshRemoteUsed.name,
host: sshRemoteUsed.host,
} : undefined,
};
})
);
// Return spawn result with SSH remote info if used
return {
...result,
sshRemote: sshRemoteUsed
? {
id: sshRemoteUsed.id,
name: sshRemoteUsed.name,
host: sshRemoteUsed.host,
}
: undefined,
};
}
)
);
// Write data to a process
ipcMain.handle(
'process:write',
withIpcErrorLogging(handlerOpts('write'), async (sessionId: string, data: string) => {
const processManager = requireProcessManager(getProcessManager);
logger.debug(`Writing to process: ${sessionId}`, LOG_CONTEXT, { sessionId, dataLength: data.length });
return processManager.write(sessionId, data);
})
);
// Write data to a process
ipcMain.handle(
'process:write',
withIpcErrorLogging(handlerOpts('write'), async (sessionId: string, data: string) => {
const processManager = requireProcessManager(getProcessManager);
logger.debug(`Writing to process: ${sessionId}`, LOG_CONTEXT, {
sessionId,
dataLength: data.length,
});
return processManager.write(sessionId, data);
})
);
// Send SIGINT to a process
ipcMain.handle(
'process:interrupt',
withIpcErrorLogging(handlerOpts('interrupt'), async (sessionId: string) => {
const processManager = requireProcessManager(getProcessManager);
logger.info(`Interrupting process: ${sessionId}`, LOG_CONTEXT, { sessionId });
return processManager.interrupt(sessionId);
})
);
// Send SIGINT to a process
ipcMain.handle(
'process:interrupt',
withIpcErrorLogging(handlerOpts('interrupt'), async (sessionId: string) => {
const processManager = requireProcessManager(getProcessManager);
logger.info(`Interrupting process: ${sessionId}`, LOG_CONTEXT, { sessionId });
return processManager.interrupt(sessionId);
})
);
// Kill a process
ipcMain.handle(
'process:kill',
withIpcErrorLogging(handlerOpts('kill'), async (sessionId: string) => {
const processManager = requireProcessManager(getProcessManager);
logger.info(`Killing process: ${sessionId}`, LOG_CONTEXT, { sessionId });
return processManager.kill(sessionId);
})
);
// Kill a process
ipcMain.handle(
'process:kill',
withIpcErrorLogging(handlerOpts('kill'), async (sessionId: string) => {
const processManager = requireProcessManager(getProcessManager);
logger.info(`Killing process: ${sessionId}`, LOG_CONTEXT, { sessionId });
return processManager.kill(sessionId);
})
);
// Resize PTY dimensions
ipcMain.handle(
'process:resize',
withIpcErrorLogging(handlerOpts('resize'), async (sessionId: string, cols: number, rows: number) => {
const processManager = requireProcessManager(getProcessManager);
return processManager.resize(sessionId, cols, rows);
})
);
// Resize PTY dimensions
ipcMain.handle(
'process:resize',
withIpcErrorLogging(
handlerOpts('resize'),
async (sessionId: string, cols: number, rows: number) => {
const processManager = requireProcessManager(getProcessManager);
return processManager.resize(sessionId, cols, rows);
}
)
);
// Get all active processes managed by the ProcessManager
ipcMain.handle(
'process:getActiveProcesses',
withIpcErrorLogging(handlerOpts('getActiveProcesses'), async () => {
const processManager = requireProcessManager(getProcessManager);
const processes = processManager.getAll();
// Return serializable process info (exclude non-serializable PTY/child process objects)
return processes.map(p => ({
sessionId: p.sessionId,
toolType: p.toolType,
pid: p.pid,
cwd: p.cwd,
isTerminal: p.isTerminal,
isBatchMode: p.isBatchMode || false,
startTime: p.startTime,
command: p.command,
args: p.args,
}));
})
);
// Get all active processes managed by the ProcessManager
ipcMain.handle(
'process:getActiveProcesses',
withIpcErrorLogging(handlerOpts('getActiveProcesses'), async () => {
const processManager = requireProcessManager(getProcessManager);
const processes = processManager.getAll();
// Return serializable process info (exclude non-serializable PTY/child process objects)
return processes.map((p) => ({
sessionId: p.sessionId,
toolType: p.toolType,
pid: p.pid,
cwd: p.cwd,
isTerminal: p.isTerminal,
isBatchMode: p.isBatchMode || false,
startTime: p.startTime,
command: p.command,
args: p.args,
}));
})
);
// Run a single command and capture only stdout/stderr (no PTY echo/prompts)
// Supports SSH remote execution when sessionSshRemoteConfig is provided
ipcMain.handle(
'process:runCommand',
withIpcErrorLogging(handlerOpts('runCommand'), async (config: {
sessionId: string;
command: string;
cwd: string;
shell?: string;
// Per-session SSH remote config (same as process:spawn)
sessionSshRemoteConfig?: {
enabled: boolean;
remoteId: string | null;
workingDirOverride?: string;
};
}) => {
const processManager = requireProcessManager(getProcessManager);
// Run a single command and capture only stdout/stderr (no PTY echo/prompts)
// Supports SSH remote execution when sessionSshRemoteConfig is provided
ipcMain.handle(
'process:runCommand',
withIpcErrorLogging(
handlerOpts('runCommand'),
async (config: {
sessionId: string;
command: string;
cwd: string;
shell?: string;
// Per-session SSH remote config (same as process:spawn)
sessionSshRemoteConfig?: {
enabled: boolean;
remoteId: string | null;
workingDirOverride?: string;
};
}) => {
const processManager = requireProcessManager(getProcessManager);
// Get the shell from settings if not provided
// Custom shell path takes precedence over the selected shell ID
let shell = config.shell || settingsStore.get('defaultShell', 'zsh');
const customShellPath = settingsStore.get('customShellPath', '');
if (customShellPath && customShellPath.trim()) {
shell = customShellPath.trim();
}
// Get the shell from settings if not provided
// Custom shell path takes precedence over the selected shell ID
let shell = config.shell || settingsStore.get('defaultShell', 'zsh');
const customShellPath = settingsStore.get('customShellPath', '');
if (customShellPath && customShellPath.trim()) {
shell = customShellPath.trim();
}
// Get shell env vars for passing to runCommand
const shellEnvVars = settingsStore.get('shellEnvVars', {}) as Record<string, string>;
// Get shell env vars for passing to runCommand
const shellEnvVars = settingsStore.get('shellEnvVars', {}) as Record<string, string>;
// ========================================================================
// SSH Remote Execution: Resolve SSH config if provided
// ========================================================================
let sshRemoteConfig: SshRemoteConfig | null = null;
// ========================================================================
// SSH Remote Execution: Resolve SSH config if provided
// ========================================================================
let sshRemoteConfig: SshRemoteConfig | null = null;
if (config.sessionSshRemoteConfig?.enabled && config.sessionSshRemoteConfig?.remoteId) {
const sshStoreAdapter = createSshRemoteStoreAdapter(settingsStore);
const sshResult = getSshRemoteConfig(sshStoreAdapter, {
sessionSshConfig: config.sessionSshRemoteConfig,
});
if (config.sessionSshRemoteConfig?.enabled && config.sessionSshRemoteConfig?.remoteId) {
const sshStoreAdapter = createSshRemoteStoreAdapter(settingsStore);
const sshResult = getSshRemoteConfig(sshStoreAdapter, {
sessionSshConfig: config.sessionSshRemoteConfig,
});
if (sshResult.config) {
sshRemoteConfig = sshResult.config;
logger.info(`Terminal command will execute via SSH`, LOG_CONTEXT, {
sessionId: config.sessionId,
remoteName: sshResult.config.name,
remoteHost: sshResult.config.host,
source: sshResult.source,
});
}
}
if (sshResult.config) {
sshRemoteConfig = sshResult.config;
logger.info(`Terminal command will execute via SSH`, LOG_CONTEXT, {
sessionId: config.sessionId,
remoteName: sshResult.config.name,
remoteHost: sshResult.config.host,
source: sshResult.source,
});
}
}
logger.debug(`Running command: ${config.command}`, LOG_CONTEXT, {
sessionId: config.sessionId,
cwd: config.cwd,
shell,
hasCustomEnvVars: Object.keys(shellEnvVars).length > 0,
sshRemote: sshRemoteConfig?.name || null,
});
logger.debug(`Running command: ${config.command}`, LOG_CONTEXT, {
sessionId: config.sessionId,
cwd: config.cwd,
shell,
hasCustomEnvVars: Object.keys(shellEnvVars).length > 0,
sshRemote: sshRemoteConfig?.name || null,
});
return processManager.runCommand(
config.sessionId,
config.command,
config.cwd,
shell,
shellEnvVars,
sshRemoteConfig
);
})
);
return processManager.runCommand(
config.sessionId,
config.command,
config.cwd,
shell,
shellEnvVars,
sshRemoteConfig
);
}
)
);
}

File diff suppressed because it is too large Load Diff