Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 111 additions & 164 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,77 +541,13 @@ const sessions = new Map<string, SessionState>();
let activeSessionId: string | null = null;
let sessionCounter = 0;

// Keep-alive interval (5 minutes) to prevent session timeout
const SESSION_KEEPALIVE_INTERVAL = 5 * 60 * 1000;
let keepAliveTimer: NodeJS.Timeout | null = null;

// Start keep-alive timer for active sessions
function startKeepAlive(): void {
if (keepAliveTimer) return;

keepAliveTimer = setInterval(async () => {
for (const [sessionId, sessionState] of sessions.entries()) {
// Only ping sessions that are actively processing to avoid noise
if (!sessionState.isProcessing) continue;

try {
// Ping the session by getting messages (lightweight operation)
await sessionState.session.getMessages();
log.info(`[${sessionId}] Keep-alive ping successful`);
} catch (error) {
log.warn(`[${sessionId}] Keep-alive ping failed:`, error);
// Session may have timed out on the backend - send idle event to frontend
// to ensure the UI doesn't stay stuck in "processing" state
if (mainWindow && !mainWindow.isDestroyed()) {
log.info(`[${sessionId}] Sending fallback idle event due to session timeout`);
mainWindow.webContents.send('copilot:idle', { sessionId });
sessionState.isProcessing = false;
}
}
}
}, SESSION_KEEPALIVE_INTERVAL);

log.info('Started session keep-alive timer');
}

// Stop keep-alive timer
function stopKeepAlive(): void {
if (keepAliveTimer) {
clearInterval(keepAliveTimer);
keepAliveTimer = null;
log.info('Stopped session keep-alive timer');
}
}

// Resume a session that has been disconnected
async function resumeDisconnectedSession(
sessionId: string,
sessionState: SessionState
): Promise<CopilotSession> {
log.info(`[${sessionId}] Attempting to resume disconnected session...`);

const client = await getClientForCwd(sessionState.cwd);
const mcpConfig = await readMcpConfig();

// Create browser tools for resumed session
const browserTools = createBrowserTools(sessionId);
log.info(
`[${sessionId}] Resuming with ${browserTools.length} tools:`,
browserTools.map((t) => t.name).join(', ')
);

const resumedSession = await client.resumeSession(sessionId, {
mcpServers: mcpConfig.mcpServers,
tools: browserTools,
onPermissionRequest: (request, invocation) =>
handlePermissionRequest(request, invocation, sessionId),
});

// Set up event handler for resumed session
resumedSession.on((event) => {
// Registers event forwarding from a CopilotSession to the renderer via IPC.
// Used after createSession and resumeSession to wire up the session.
function registerSessionEventForwarding(sessionId: string, session: CopilotSession): void {
session.on((event) => {
if (!mainWindow || mainWindow.isDestroyed()) return;

log.info(`[${sessionId}] Event:`, event.type);
console.log(`[${sessionId}] Event:`, event.type);

if (event.type === 'assistant.message_delta') {
mainWindow.webContents.send('copilot:delta', { sessionId, content: event.data.deltaContent });
Expand All @@ -623,15 +559,15 @@ async function resumeDisconnectedSession(
mainWindow.webContents.send('copilot:idle', { sessionId });
requestUserAttention();
} else if (event.type === 'tool.execution_start') {
log.info(`[${sessionId}] Tool start FULL:`, JSON.stringify(event.data, null, 2));
console.log(`[${sessionId}] Tool start FULL:`, JSON.stringify(event.data, null, 2));
mainWindow.webContents.send('copilot:tool-start', {
sessionId,
toolCallId: event.data.toolCallId,
toolName: event.data.toolName,
input: event.data.arguments || (event.data as Record<string, unknown>),
});
} else if (event.type === 'tool.execution_complete') {
log.info(`[${sessionId}] Tool end FULL:`, JSON.stringify(event.data, null, 2));
console.log(`[${sessionId}] Tool end FULL:`, JSON.stringify(event.data, null, 2));
const completeData = event.data as Record<string, unknown>;
mainWindow.webContents.send('copilot:tool-end', {
sessionId,
Expand All @@ -641,7 +577,7 @@ async function resumeDisconnectedSession(
output: event.data.result?.content || completeData.output,
});
} else if (event.type === 'session.error') {
log.info(`[${sessionId}] Session error:`, event.data);
console.log(`[${sessionId}] Session error:`, event.data);
const errorMessage = event.data?.message || JSON.stringify(event.data);

// Auto-repair tool_result errors (duplicate or orphaned after compaction)
Expand Down Expand Up @@ -675,10 +611,10 @@ async function resumeDisconnectedSession(
messagesLength: event.data.messagesLength,
});
} else if (event.type === 'session.compaction_start') {
log.info(`[${sessionId}] Compaction started`);
console.log(`[${sessionId}] Compaction started`);
mainWindow.webContents.send('copilot:compactionStart', { sessionId });
} else if (event.type === 'session.compaction_complete') {
log.info(`[${sessionId}] Compaction complete:`, event.data);
console.log(`[${sessionId}] Compaction complete:`, event.data);
mainWindow.webContents.send('copilot:compactionComplete', {
sessionId,
success: event.data.success,
Expand All @@ -690,6 +626,75 @@ async function resumeDisconnectedSession(
});
}
});
}

// Keep-alive interval (5 minutes) to prevent session timeout
const SESSION_KEEPALIVE_INTERVAL = 5 * 60 * 1000;
let keepAliveTimer: NodeJS.Timeout | null = null;

// Start keep-alive timer for active sessions
function startKeepAlive(): void {
if (keepAliveTimer) return;

keepAliveTimer = setInterval(async () => {
for (const [sessionId, sessionState] of sessions.entries()) {
// Only ping sessions that are actively processing to avoid noise
if (!sessionState.isProcessing) continue;

try {
// Ping the session by getting messages (lightweight operation)
await sessionState.session.getMessages();
log.info(`[${sessionId}] Keep-alive ping successful`);
} catch (error) {
log.warn(`[${sessionId}] Keep-alive ping failed:`, error);
// Session may have timed out on the backend - send idle event to frontend
// to ensure the UI doesn't stay stuck in "processing" state
if (mainWindow && !mainWindow.isDestroyed()) {
log.info(`[${sessionId}] Sending fallback idle event due to session timeout`);
mainWindow.webContents.send('copilot:idle', { sessionId });
sessionState.isProcessing = false;
}
}
}
}, SESSION_KEEPALIVE_INTERVAL);

log.info('Started session keep-alive timer');
}

// Stop keep-alive timer
function stopKeepAlive(): void {
if (keepAliveTimer) {
clearInterval(keepAliveTimer);
keepAliveTimer = null;
log.info('Stopped session keep-alive timer');
}
}

// Resume a session that has been disconnected
async function resumeDisconnectedSession(
sessionId: string,
sessionState: SessionState
): Promise<CopilotSession> {
log.info(`[${sessionId}] Attempting to resume disconnected session...`);

const client = await getClientForCwd(sessionState.cwd);
const mcpConfig = await readMcpConfig();

// Create browser tools for resumed session
const browserTools = createBrowserTools(sessionId);
log.info(
`[${sessionId}] Resuming with ${browserTools.length} tools:`,
browserTools.map((t) => t.name).join(', ')
);

const resumedSession = await client.resumeSession(sessionId, {
mcpServers: mcpConfig.mcpServers,
tools: browserTools,
onPermissionRequest: (request, invocation) =>
handlePermissionRequest(request, invocation, sessionId),
});

registerSessionEventForwarding(sessionId, resumedSession);

// Update session state with new session object
sessionState.session = resumedSession;
Expand Down Expand Up @@ -1483,90 +1488,7 @@ Browser tools available: browser_navigate, browser_click, browser_fill, browser_

const sessionId = newSession.sessionId; // Use SDK's session ID

// Set up event handler for this session
newSession.on((event) => {
if (!mainWindow || mainWindow.isDestroyed()) return;

// Always forward events - frontend routes by sessionId
console.log(`[${sessionId}] Event:`, event.type);

if (event.type === 'assistant.message_delta') {
mainWindow.webContents.send('copilot:delta', { sessionId, content: event.data.deltaContent });
} else if (event.type === 'assistant.message') {
mainWindow.webContents.send('copilot:message', { sessionId, content: event.data.content });
} else if (event.type === 'session.idle') {
const currentSessionState = sessions.get(sessionId);
if (currentSessionState) currentSessionState.isProcessing = false;
mainWindow.webContents.send('copilot:idle', { sessionId });
requestUserAttention();
} else if (event.type === 'tool.execution_start') {
console.log(`[${sessionId}] Tool start FULL:`, JSON.stringify(event.data, null, 2));
mainWindow.webContents.send('copilot:tool-start', {
sessionId,
toolCallId: event.data.toolCallId,
toolName: event.data.toolName,
input: event.data.arguments || (event.data as Record<string, unknown>),
});
} else if (event.type === 'tool.execution_complete') {
console.log(`[${sessionId}] Tool end FULL:`, JSON.stringify(event.data, null, 2));
const completeData = event.data as Record<string, unknown>;
mainWindow.webContents.send('copilot:tool-end', {
sessionId,
toolCallId: event.data.toolCallId,
toolName: completeData.toolName,
input: completeData.arguments || completeData,
output: event.data.result?.content || completeData.output,
});
} else if (event.type === 'session.error') {
console.log(`[${sessionId}] Session error:`, event.data);
const errorMessage = event.data?.message || JSON.stringify(event.data);

// Auto-repair tool_result errors (duplicate or orphaned after compaction)
if (
errorMessage.includes('multiple `tool_result` blocks') ||
errorMessage.includes('each tool_use must have a single result') ||
errorMessage.includes('unexpected `tool_use_id`') ||
errorMessage.includes('Each `tool_result` block must have a corresponding `tool_use`')
) {
log.info(`[${sessionId}] Detected tool_result corruption error, attempting auto-repair...`);
repairDuplicateToolResults(sessionId).then((repaired) => {
if (repaired) {
mainWindow?.webContents.send('copilot:error', {
sessionId,
message: 'Session repaired. Please resend your last message.',
isRepaired: true,
});
} else {
mainWindow?.webContents.send('copilot:error', { sessionId, message: errorMessage });
}
});
return;
}

mainWindow.webContents.send('copilot:error', { sessionId, message: errorMessage });
} else if (event.type === 'session.usage_info') {
mainWindow.webContents.send('copilot:usageInfo', {
sessionId,
tokenLimit: event.data.tokenLimit,
currentTokens: event.data.currentTokens,
messagesLength: event.data.messagesLength,
});
} else if (event.type === 'session.compaction_start') {
console.log(`[${sessionId}] Compaction started`);
mainWindow.webContents.send('copilot:compactionStart', { sessionId });
} else if (event.type === 'session.compaction_complete') {
console.log(`[${sessionId}] Compaction complete:`, event.data);
mainWindow.webContents.send('copilot:compactionComplete', {
sessionId,
success: event.data.success,
preCompactionTokens: event.data.preCompactionTokens,
postCompactionTokens: event.data.postCompactionTokens,
tokensRemoved: event.data.tokensRemoved,
summaryContent: event.data.summaryContent,
error: event.data.error,
});
}
});
registerSessionEventForwarding(sessionId, newSession);

sessions.set(sessionId, {
session: newSession,
Expand Down Expand Up @@ -2477,16 +2399,41 @@ ipcMain.handle('copilot:setModel', async (_event, data: { sessionId: string; mod

const sessionState = sessions.get(data.sessionId);
if (sessionState) {
// Destroy old session before creating new one
const { cwd, client } = sessionState;

// Destroy local session state (conversation history is preserved on server)
console.log(`Destroying session ${data.sessionId} before model change to ${data.model}`);
await sessionState.session.destroy();
sessions.delete(data.sessionId);

// Create replacement session with new model (keep same cwd)
const newSessionId = await createNewSession(data.model, sessionState.cwd);
const newSessionState = sessions.get(newSessionId)!;
console.log(`Sessions after model change: ${sessions.size} active`);
return { sessionId: newSessionId, model: data.model, cwd: newSessionState.cwd };
const mcpConfig = await readMcpConfig();
const browserTools = createBrowserTools(data.sessionId);

// Resume the same session with the new model — preserves conversation context
const resumedSession = await client.resumeSession(data.sessionId, {
model: data.model,
mcpServers: mcpConfig.mcpServers,
tools: browserTools,
onPermissionRequest: (request, invocation) =>
handlePermissionRequest(request, invocation, resumedSession.sessionId),
});

const resumedSessionId = resumedSession.sessionId;
registerSessionEventForwarding(resumedSessionId, resumedSession);

sessions.set(resumedSessionId, {
session: resumedSession,
client,
model: data.model,
cwd,
alwaysAllowed: new Set(sessionState.alwaysAllowed),
allowedPaths: new Set(sessionState.allowedPaths),
isProcessing: false,
});
activeSessionId = resumedSessionId;

console.log(`Session ${resumedSessionId} resumed with model ${data.model}`);
return { sessionId: resumedSessionId, model: data.model, cwd };
}

return { model: data.model };
Expand Down
Loading