diff --git a/src/main/main.ts b/src/main/main.ts index cd5fc55..442e8c5 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -78,6 +78,7 @@ import * as worktree from './worktree'; import * as ptyManager from './pty'; import * as browserManager from './browser'; import { createBrowserTools } from './browserTools'; +import { createSessionTools } from './sessionTools'; import { voiceService } from './voiceService'; import { whisperModelManager } from './whisperModelManager'; @@ -1536,16 +1537,63 @@ async function createNewSession(model?: string, cwd?: string): Promise { // Create browser tools for this session const browserTools = createBrowserTools(generatedSessionId); + + // Create session management tools + const sessionTools = createSessionTools({ + sessionId: generatedSessionId, + getVerifiedModels, + getSessions: () => { + // Return a simplified view of sessions for the tools + const simplified = new Map(); + sessions.forEach((state, id) => { + simplified.set(id, { model: state.model, cwd: state.cwd }); + }); + return simplified; + }, + getActiveSessionId: () => activeSessionId, + createSessionTab: async (options) => { + // Create a new session in the main process + const newSessionId = await createNewSession(options.model, options.cwd); + const newSessionState = sessions.get(newSessionId)!; + + // Emit event for renderer to create the tab + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('copilot:session-created-by-tool', { + sessionId: newSessionId, + model: newSessionState.model, + cwd: newSessionState.cwd, + initialPrompt: options.initialPrompt, + }); + } + + return { + sessionId: newSessionId, + model: newSessionState.model, + cwd: newSessionState.cwd, + }; + }, + closeSessionTab: async (targetSessionId) => { + // Emit event for renderer to close the tab + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('copilot:close-session-by-tool', { + sessionId: targetSessionId, + }); + } + return { success: true }; + }, + }); + + const allTools = [...browserTools, ...sessionTools]; console.log( - `[${generatedSessionId}] Registering ${browserTools.length} tools:`, - browserTools.map((t) => t.name) + `[${generatedSessionId}] Registering ${allTools.length} tools:`, + allTools.map((t) => t.name) ); const newSession = await client.createSession({ sessionId: generatedSessionId, model: sessionModel, mcpServers: mcpConfig.mcpServers, - tools: browserTools, + tools: allTools, customAgents, onPermissionRequest: (request, invocation) => handlePermissionRequest(request, invocation, newSession.sessionId), @@ -1582,6 +1630,19 @@ Browser tools available: browser_navigate, browser_click, browser_fill, browser_ - Do NOT say "I cannot take screenshots of desktop apps" - you CAN via Playwright - Use browser_navigate to connect to the running Electron app, then browser_screenshot to capture it - This is the CORRECT way to capture visual evidence of Electron app features you've built or tested + +## Session Management Tools + +You have tools to manage Cooper sessions: +- cooper_create_session: Create a new session tab (with optional cwd, model, initial message). Defaults to current session's folder and model. +- cooper_create_worktree_session: Create a git worktree and open a session in it. Use for parallel branch development. Requires a git repository path and branch name. +- cooper_list_sessions: List all active Copilot sessions (tabs) +- cooper_close_session: Close a session tab +- cooper_get_current_session: Get info about the current session +- cooper_get_models: List available AI models +- cooper_get_favorite_models: Get user's favorite models + +Use cooper_create_session for simple parallel sessions. Use cooper_create_worktree_session when the user wants to work on a different git branch in isolation. `, }, }); diff --git a/src/main/sessionTools.ts b/src/main/sessionTools.ts new file mode 100644 index 0000000..e4ae24c --- /dev/null +++ b/src/main/sessionTools.ts @@ -0,0 +1,286 @@ +/** + * Session Management Tools for Copilot SDK + * + * Defines custom tools that allow the AI to manage Cooper UI state: + * - Create/list/close session tabs + * - Create worktree sessions (git-based parallel development) + * - Query available models + * + * These tools are registered with the Copilot SDK session and can be invoked by the AI. + */ + +import { z } from 'zod'; +import { defineTool, Tool } from '@github/copilot-sdk'; +import Store from 'electron-store'; +import * as worktree from './worktree'; + +// Type for verified models (matches main.ts ModelInfo) +interface ModelInfo { + id: string; + name: string; + version: string; + vendor: string; + model_picker_enabled?: boolean; + preview?: boolean; + tier?: 'premium' | 'standard' | 'fast_cheap'; + source?: 'api' | 'fallback'; +} + +// Type for session info returned by list +interface SessionInfo { + id: string; + model: string; + cwd: string; + isActive: boolean; +} + +// Store instance reference +const store = new Store(); + +// Options for creating session tools +interface SessionToolsOptions { + /** The current session ID */ + sessionId: string; + /** Function to get available models */ + getVerifiedModels: () => ModelInfo[]; + /** Function to get active sessions map */ + getSessions: () => Map; + /** Function to get the currently active session ID */ + getActiveSessionId: () => string | null; + /** Function to create a new session and open it as a tab */ + createSessionTab: (options: { + cwd?: string; + model?: string; + initialPrompt?: string; + }) => Promise<{ sessionId: string; model: string; cwd: string }>; + /** Function to close a session tab */ + closeSessionTab: (sessionId: string) => Promise<{ success: boolean; error?: string }>; +} + +/** + * Create session management tools for a specific Copilot session + */ +export function createSessionTools(options: SessionToolsOptions): Tool[] { + const { + sessionId, + getVerifiedModels, + getSessions, + getActiveSessionId, + createSessionTab, + closeSessionTab, + } = options; + + return [ + // Create a new session tab + defineTool('cooper_create_session', { + description: + 'Create a new Copilot session tab in Cooper. The new tab opens in the background (does not switch away from current session). Optionally sends an initial message to start the conversation in the new tab. For git worktree sessions, use cooper_create_worktree_session instead.', + parameters: z.object({ + cwd: z + .string() + .optional() + .describe( + 'Working directory for the new session. If not specified, uses the same directory as the current session.' + ), + model: z + .string() + .optional() + .describe( + 'Model ID to use (e.g., "claude-sonnet-4", "gpt-5.2"). If not specified, uses the same model as the current session.' + ), + initialPrompt: z + .string() + .optional() + .describe('Optional message to send immediately after creating the session.'), + }), + handler: async (args) => { + // Default to current session's cwd and model if not specified + const currentSession = getSessions().get(sessionId); + const targetCwd = args.cwd || currentSession?.cwd; + const targetModel = args.model || currentSession?.model; + + try { + const result = await createSessionTab({ + cwd: targetCwd, + model: targetModel, + initialPrompt: args.initialPrompt, + }); + return JSON.stringify( + { + message: 'Session created successfully', + sessionId: result.sessionId, + model: result.model, + cwd: result.cwd, + }, + null, + 2 + ); + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + }, + }), + + // Create a worktree session (git-specific) + defineTool('cooper_create_worktree_session', { + description: + 'Create a new git worktree and open a Copilot session tab in it. Use this for parallel development on multiple branches. The repo path MUST be a git repository. Creates an isolated working directory with the specified branch, then opens a new tab there. The new tab opens in the background.', + parameters: z.object({ + repoPath: z + .string() + .describe( + 'Full path to the git repository to create a worktree from. Must be an existing git repository.' + ), + branch: z + .string() + .describe( + 'Branch name to checkout or create in the worktree. Will be sanitized for git compatibility.' + ), + model: z + .string() + .optional() + .describe( + 'Model ID to use (e.g., "claude-sonnet-4", "gpt-5.2"). If not specified, uses the same model as the current session.' + ), + initialPrompt: z + .string() + .optional() + .describe('Optional message to send immediately after creating the session.'), + }), + handler: async (args) => { + // Create the git worktree + const worktreeResult = await worktree.createWorktreeSession(args.repoPath, args.branch); + if (!worktreeResult.success || !worktreeResult.session) { + return { error: worktreeResult.error || 'Failed to create worktree' }; + } + + // Default model to current session's model if not specified + const currentSession = getSessions().get(sessionId); + const targetModel = args.model || currentSession?.model; + + // Create a session tab in the worktree directory + try { + const result = await createSessionTab({ + cwd: worktreeResult.session.worktreePath, + model: targetModel, + initialPrompt: args.initialPrompt, + }); + return JSON.stringify( + { + message: 'Worktree session created successfully', + sessionId: result.sessionId, + model: result.model, + cwd: result.cwd, + branch: worktreeResult.session.branch, + worktreeId: worktreeResult.session.id, + }, + null, + 2 + ); + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + }, + }), + + // List active Copilot sessions (tabs) + defineTool('cooper_list_sessions', { + description: + 'List all active Copilot sessions (tabs) in Cooper. Shows session ID, model, working directory, and whether it is the active tab.', + parameters: z.object({}), + handler: async () => { + const sessionsMap = getSessions(); + const activeId = getActiveSessionId(); + const sessionList: SessionInfo[] = []; + + sessionsMap.forEach((session, id) => { + sessionList.push({ + id, + model: session.model, + cwd: session.cwd, + isActive: id === activeId, + }); + }); + + return JSON.stringify(sessionList, null, 2); + }, + }), + + // Close a session tab + defineTool('cooper_close_session', { + description: + 'Close a Copilot session tab in Cooper. Cannot close the current session (the one running this tool).', + parameters: z.object({ + sessionId: z + .string() + .describe('The session ID to close. Use cooper_list_sessions to find session IDs.'), + }), + handler: async (args) => { + if (args.sessionId === sessionId) { + return { error: 'Cannot close the current session from within itself.' }; + } + try { + const result = await closeSessionTab(args.sessionId); + if (!result.success) { + return { error: result.error }; + } + return `Session ${args.sessionId} closed successfully.`; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + }, + }), + + // Get available AI models + defineTool('cooper_get_models', { + description: + 'List all available AI models that can be used with Copilot. Returns model ID, name, vendor, and tier.', + parameters: z.object({}), + handler: async () => { + const models = getVerifiedModels(); + const modelSummary = models.map((m) => ({ + id: m.id, + name: m.name, + vendor: m.vendor, + tier: m.tier || 'standard', + preview: m.preview || false, + })); + return JSON.stringify(modelSummary, null, 2); + }, + }), + + // Get current session info + defineTool('cooper_get_current_session', { + description: + 'Get information about the current Copilot session, including session ID, model, and working directory.', + parameters: z.object({}), + handler: async () => { + const sessionsMap = getSessions(); + const session = sessionsMap.get(sessionId); + if (!session) { + return { error: 'Current session not found' }; + } + return JSON.stringify( + { + sessionId, + model: session.model, + cwd: session.cwd, + }, + null, + 2 + ); + }, + }), + + // Get favorite models + defineTool('cooper_get_favorite_models', { + description: + "List the user's favorite AI models. These are shown at the top of the model selector in Cooper.", + parameters: z.object({}), + handler: async () => { + const favoriteIds = (store.get('favoriteModels') as string[]) || []; + return JSON.stringify(favoriteIds, null, 2); + }, + }), + ]; +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 91e9cb9..01d6605 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -348,6 +348,28 @@ const electronAPI = { ipcRenderer.on('copilot:yoloModeChanged', handler); return () => ipcRenderer.removeListener('copilot:yoloModeChanged', handler); }, + // Session management events from tools + onSessionCreatedByTool: ( + callback: (data: { + sessionId: string; + model: string; + cwd: string; + initialPrompt?: string; + }) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + data: { sessionId: string; model: string; cwd: string; initialPrompt?: string } + ): void => callback(data); + ipcRenderer.on('copilot:session-created-by-tool', handler); + return () => ipcRenderer.removeListener('copilot:session-created-by-tool', handler); + }, + onCloseSessionByTool: (callback: (data: { sessionId: string }) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, data: { sessionId: string }): void => + callback(data); + ipcRenderer.on('copilot:close-session-by-tool', handler); + return () => ipcRenderer.removeListener('copilot:close-session-by-tool', handler); + }, getAlwaysAllowed: (sessionId: string): Promise => { return ipcRenderer.invoke('copilot:getAlwaysAllowed', sessionId); }, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index cefb30f..c7e7295 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2145,6 +2145,78 @@ Only output ${RALPH_COMPLETION_SIGNAL} when ALL items above are verified complet } }); + // Handle session creation from tools (e.g., cooper_create_session) + const unsubscribeSessionCreatedByTool = window.electronAPI.copilot.onSessionCreatedByTool( + async (data) => { + const newTab: TabState = { + id: data.sessionId, + name: generateTabName(), + messages: [], + model: data.model, + cwd: data.cwd, + isProcessing: false, + activeTools: [], + hasUnreadCompletion: false, + pendingConfirmations: [], + needsTitle: true, + alwaysAllowed: [], + editedFiles: [], + untrackedFiles: [], + fileViewMode: 'flat', + currentIntent: null, + currentIntentTimestamp: null, + gitBranchRefresh: 0, + activeAgentName: undefined, + }; + setTabs((prev) => [...prev, newTab]); + // Don't switch to the new tab - stay on current session + + // If there's an initial prompt, send it + if (data.initialPrompt) { + try { + await window.electronAPI.copilot.send(data.sessionId, data.initialPrompt); + setTabs((prev) => + prev.map((tab) => + tab.id === data.sessionId + ? { + ...tab, + isProcessing: true, + messages: [ + ...tab.messages, + { role: 'user' as const, content: data.initialPrompt! }, + ], + } + : tab + ) + ); + } catch (error) { + console.error('Failed to send initial prompt to new session:', error); + } + } + } + ); + + // Handle session close from tools (e.g., cooper_close_session) + const unsubscribeCloseSessionByTool = window.electronAPI.copilot.onCloseSessionByTool( + async (data) => { + // Close the session in the backend + try { + await window.electronAPI.copilot.closeSession(data.sessionId); + } catch (error) { + console.error('Failed to close session:', error); + } + // Remove the tab from the UI + setTabs((prev) => { + const remaining = prev.filter((tab) => tab.id !== data.sessionId); + // If we closed the active tab, switch to another + if (activeTabId === data.sessionId && remaining.length > 0) { + setActiveTabId(remaining[remaining.length - 1].id); + } + return remaining; + }); + } + ); + return () => { unsubscribeReady(); unsubscribeDelta(); @@ -2161,6 +2233,8 @@ Only output ${RALPH_COMPLETION_SIGNAL} when ALL items above are verified complet unsubscribeCompactionStart(); unsubscribeCompactionComplete(); unsubscribeYoloModeChanged(); + unsubscribeSessionCreatedByTool(); + unsubscribeCloseSessionByTool(); }; }, []);