mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
Merge pull request #214 from pedramamini/code-refactor
perf: optimize hot path performance in main process
This commit is contained in:
514
src/__tests__/main/performance-optimizations.test.ts
Normal file
514
src/__tests__/main/performance-optimizations.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
6098
src/main/index.ts
6098
src/main/index.ts
File diff suppressed because it is too large
Load Diff
@@ -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 [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user