From d287288601a3c9be528a2669820fdbae026adffa Mon Sep 17 00:00:00 2001 From: yushen Date: Wed, 14 Jan 2026 18:22:31 +0800 Subject: [PATCH] refactor: make each session run its own agent process - Change from shared agent to per-session agent architecture - Rename /agent command to /default for setting default agent - Update Conductor to manage agent lifecycle per session - Simplify IPC handlers and frontend state management - Each session now independently starts and stops its agent Co-Authored-By: Claude Opus 4.5 --- src/main/cli.ts | 104 +++--- src/main/conductor/Conductor.ts | 367 +++++++++++++--------- src/main/index.ts | 11 + src/main/ipc/handlers.ts | 58 ++-- src/preload/index.ts | 16 +- src/renderer/src/App.tsx | 44 ++- src/renderer/src/components/Settings.tsx | 59 +--- src/renderer/src/components/StatusBar.tsx | 65 ++-- src/renderer/src/hooks/useApp.ts | 104 +++--- src/shared/electron-api.d.ts | 20 +- src/shared/ipc-channels.ts | 8 +- 11 files changed, 412 insertions(+), 444 deletions(-) diff --git a/src/main/cli.ts b/src/main/cli.ts index 1235a3b59d..f7db3032c0 100644 --- a/src/main/cli.ts +++ b/src/main/cli.ts @@ -46,7 +46,7 @@ const c = { interface CLIState { conductor: Conductor currentSession: MulticaSession | null - currentAgent: AgentConfig | null + defaultAgentId: string // Agent to use for new sessions isProcessing: boolean isCancelling: boolean logFile: string | null @@ -96,7 +96,7 @@ ${c.bold}Commands:${c.reset} ${c.cyan}/resume ${c.reset} Resume an existing session ${c.cyan}/delete ${c.reset} Delete a session ${c.cyan}/history${c.reset} Show current session message history - ${c.cyan}/agent ${c.reset} Switch to a different agent + ${c.cyan}/default ${c.reset} Set default agent for new sessions ${c.cyan}/agents${c.reset} List available agents ${c.cyan}/doctor${c.reset} Check agent installations ${c.cyan}/status${c.reset} Show current status @@ -105,6 +105,7 @@ ${c.bold}Commands:${c.reset} ${c.bold}Usage:${c.reset} Type any text to send as a prompt to the current session. + Each session runs its own agent process. Press Ctrl+C to cancel a running request. Press Ctrl+C twice to force quit. `) @@ -152,24 +153,18 @@ async function cmdNewSession(state: CLIState, cwd?: string) { return } - // Ensure agent is running - if (!state.conductor.isAgentRunning()) { - const agentId = state.currentAgent?.id || 'opencode' - const config = DEFAULT_AGENTS[agentId] - if (!config) { - printError(`Unknown agent: ${agentId}`) - return - } - printInfo(`Starting ${config.name}...`) - await state.conductor.startAgent(config) - state.currentAgent = config + const config = DEFAULT_AGENTS[state.defaultAgentId] + if (!config) { + printError(`Unknown agent: ${state.defaultAgentId}`) + return } - printInfo(`Creating session in ${targetCwd}...`) - const session = await state.conductor.createSession(targetCwd) + printInfo(`Creating session with ${config.name} in ${targetCwd}...`) + const session = await state.conductor.createSession(targetCwd, config) state.currentSession = session printSuccess(`Session created: ${session.id.slice(0, 8)}`) + printInfo(`Agent: ${config.name}`) printInfo(`Working directory: ${session.workingDirectory}`) } @@ -188,24 +183,18 @@ async function cmdResumeSession(state: CLIState, sessionId: string) { return } - // Ensure agent is running const agentConfig = DEFAULT_AGENTS[match.agentId] if (!agentConfig) { printError(`Unknown agent: ${match.agentId}`) return } - if (!state.conductor.isAgentRunning() || state.currentAgent?.id !== agentConfig.id) { - printInfo(`Starting ${agentConfig.name}...`) - await state.conductor.startAgent(agentConfig) - state.currentAgent = agentConfig - } - - printInfo(`Resuming session ${match.id.slice(0, 8)}...`) + printInfo(`Resuming session ${match.id.slice(0, 8)} with ${agentConfig.name}...`) const session = await state.conductor.resumeSession(match.id) state.currentSession = session printSuccess(`Session resumed: ${session.id.slice(0, 8)}`) + printInfo(`Agent: ${agentConfig.name}`) printInfo(`Working directory: ${session.workingDirectory}`) printDim('Note: Agent state is not restored. Previous messages are stored for display.') } @@ -392,9 +381,10 @@ async function cmdDoctor(): Promise { return results } -async function cmdSwitchAgent(state: CLIState, agentId: string) { +async function cmdSetDefaultAgent(state: CLIState, agentId: string) { if (!agentId) { - printError('Usage: /agent ') + printError('Usage: /default ') + print(`Current default: ${state.defaultAgentId}`) return } @@ -405,36 +395,32 @@ async function cmdSwitchAgent(state: CLIState, agentId: string) { return } - printInfo(`Stopping current agent...`) - await state.conductor.stopAgent() - - printInfo(`Starting ${config.name}...`) - await state.conductor.startAgent(config) - state.currentAgent = config - state.currentSession = null - - printSuccess(`Switched to ${config.name}`) - printInfo('Use /new to create a session with this agent.') + state.defaultAgentId = agentId + printSuccess(`Default agent set to ${config.name}`) + printInfo('New sessions will use this agent. Use /new to create a session.') } async function cmdStatus(state: CLIState) { print(`\n${c.bold}Status:${c.reset}`) - // Agent - if (state.currentAgent) { - print(` Agent: ${c.green}${state.currentAgent.name}${c.reset}`) - print(` Running: ${state.conductor.isAgentRunning() ? `${c.green}Yes${c.reset}` : `${c.red}No${c.reset}`}`) - } else { - print(` Agent: ${c.dim}None${c.reset}`) - } + // Default agent + const defaultConfig = DEFAULT_AGENTS[state.defaultAgentId] + print(` Default Agent: ${c.cyan}${defaultConfig?.name || state.defaultAgentId}${c.reset}`) - // Session + // Running sessions + const runningIds = state.conductor.getRunningSessionIds() + print(` Running Sessions: ${c.green}${runningIds.length}${c.reset}`) + + // Current session if (state.currentSession) { - print(` Session: ${c.green}${state.currentSession.id.slice(0, 8)}${c.reset}`) + const isRunning = state.conductor.isSessionRunning(state.currentSession.id) + const agentConfig = state.conductor.getSessionAgent(state.currentSession.id) + print(` Current Session: ${c.green}${state.currentSession.id.slice(0, 8)}${c.reset}`) + print(` Agent: ${agentConfig?.name || 'unknown'} (${isRunning ? `${c.green}running${c.reset}` : `${c.red}stopped${c.reset}`})`) print(` Directory: ${state.currentSession.workingDirectory}`) print(` Status: ${state.currentSession.status}`) } else { - print(` Session: ${c.dim}None${c.reset}`) + print(` Current Session: ${c.dim}None${c.reset}`) } // Processing @@ -529,7 +515,7 @@ async function runInteractiveMode(state: CLIState) { const sessionMarker = state.currentSession ? `${c.green}[${state.currentSession.id.slice(0, 8)}]${c.reset}` : `${c.dim}[no session]${c.reset}` - const agentMarker = state.currentAgent ? state.currentAgent.id : 'none' + const agentMarker = state.defaultAgentId rl.question(`${sessionMarker} ${c.dim}${agentMarker}${c.reset} > `, async (input) => { const trimmed = input.trim() @@ -570,9 +556,9 @@ async function runInteractiveMode(state: CLIState) { case 'hist': await cmdHistory(state) break - case 'agent': - case 'a': - await cmdSwitchAgent(state, arg) + case 'default': + case 'd': + await cmdSetDefaultAgent(state, arg) break case 'agents': await cmdAgents() @@ -616,21 +602,15 @@ async function runInteractiveMode(state: CLIState) { async function runOneShotPrompt(state: CLIState, prompt: string, options: { cwd?: string }) { const cwd = options.cwd || process.cwd() - // Start default agent - const agentId = 'opencode' - const config = DEFAULT_AGENTS[agentId] + const config = DEFAULT_AGENTS[state.defaultAgentId] if (!config) { - printError(`Unknown agent: ${agentId}`) + printError(`Unknown agent: ${state.defaultAgentId}`) process.exit(1) } - printInfo(`Starting ${config.name}...`) - await state.conductor.startAgent(config) - state.currentAgent = config - - // Create session - printInfo(`Creating session in ${cwd}...`) - const session = await state.conductor.createSession(cwd) + // Create session (agent starts automatically) + printInfo(`Creating session with ${config.name} in ${cwd}...`) + const session = await state.conductor.createSession(cwd, config) state.currentSession = session // Send prompt @@ -648,7 +628,7 @@ async function cleanup(state: CLIState) { writeFileSync(state.logFile, JSON.stringify(state.sessionLog, null, 2)) printInfo(`Session log saved to: ${state.logFile}`) } - await state.conductor.stopAgent() + await state.conductor.stopAllSessions() } async function main() { @@ -742,7 +722,7 @@ async function main() { const state: CLIState = { conductor, currentSession: null, - currentAgent: null, + defaultAgentId: 'opencode', isProcessing: false, isCancelling: false, logFile, diff --git a/src/main/conductor/Conductor.ts b/src/main/conductor/Conductor.ts index 802f6ded7f..d9c6403d16 100644 --- a/src/main/conductor/Conductor.ts +++ b/src/main/conductor/Conductor.ts @@ -29,6 +29,7 @@ export interface ConductorEvents { onPermissionRequest?: ( params: RequestPermissionRequest ) => Promise + onStatusChange?: () => void } export interface ConductorOptions { @@ -39,16 +40,23 @@ export interface ConductorOptions { storagePath?: string } +/** Per-session agent state */ +interface SessionAgent { + agentProcess: AgentProcess + connection: ClientSideConnection + agentConfig: AgentConfig + agentSessionId: string +} + export class Conductor { - private agentProcess: AgentProcess | null = null - private connection: ClientSideConnection | null = null - private currentAgentConfig: AgentConfig | null = null private events: ConductorEvents private sessionStore: SessionStore | null = null private skipPersistence: boolean - // Current active Multica session ID - private activeSessionId: string | null = null + // Map of Multica sessionId -> agent state (each session has its own process) + private sessions: Map = new Map() + // Set of session IDs currently processing a request + private processingSessions: Set = new Set() // In-memory session for CLI mode (when persistence is skipped) private inMemorySession: MulticaSession | null = null @@ -70,91 +78,106 @@ export class Conductor { } /** - * Start an ACP agent + * Start an agent process for a session (internal helper) */ - async startAgent(config: AgentConfig): Promise { - console.log(`[Conductor] Starting agent: ${config.name} (${config.command} ${config.args.join(' ')})`) - - // Stop existing agent if running - await this.stopAgent() + private async startAgentForSession( + sessionId: string, + config: AgentConfig, + cwd: string + ): Promise<{ connection: ClientSideConnection; agentSessionId: string }> { + console.log(`[Conductor] Starting agent for session ${sessionId}: ${config.name}`) // Start the agent subprocess - this.agentProcess = new AgentProcess(config) - await this.agentProcess.start() + const agentProcess = new AgentProcess(config) + await agentProcess.start() // Create ACP connection using the SDK const stream = ndJsonStream( - this.agentProcess.getStdinWeb(), - this.agentProcess.getStdoutWeb() + agentProcess.getStdinWeb(), + agentProcess.getStdoutWeb() ) // Create client-side connection with our Client implementation - this.connection = new ClientSideConnection((_agent) => this.createClient(), stream) + const connection = new ClientSideConnection( + (_agent) => this.createClient(sessionId), + stream + ) console.log(`[Conductor] Sending ACP initialize request (protocol v${PROTOCOL_VERSION})`) // Initialize the ACP connection - const initResult = await this.connection.initialize({ + const initResult = await connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { - // Declare what capabilities we support fs: { - readTextFile: false, // V2: implement file system access + readTextFile: false, writeTextFile: false, }, - terminal: false, // V2: implement terminal support + terminal: false, }, }) console.log(`[Conductor] ACP connected to ${config.name}`) console.log(`[Conductor] Protocol version: ${initResult.protocolVersion}`) console.log(`[Conductor] Agent info:`, initResult.agentInfo) - this.currentAgentConfig = config - - // Handle agent process exit - this.agentProcess.onExit((code, signal) => { - console.log(`[Conductor] Agent exited (code: ${code}, signal: ${signal})`) - this.connection = null - this.agentProcess = null - }) - } - - /** - * Stop the current agent - */ - async stopAgent(): Promise { - if (this.agentProcess) { - console.log(`[Conductor] Stopping agent: ${this.currentAgentConfig?.name}`) - await this.agentProcess.stop() - this.agentProcess = null - this.connection = null - this.currentAgentConfig = null - this.activeSessionId = null - console.log(`[Conductor] Agent stopped`) - } - } - - /** - * Create a new session with the agent - */ - async createSession(cwd: string): Promise { - if (!this.connection || !this.currentAgentConfig) { - throw new Error('No agent is running') - } // Create ACP session - const result = await this.connection.newSession({ + const acpResult = await connection.newSession({ cwd, - mcpServers: [], // V2: support MCP servers + mcpServers: [], }) + // Handle agent process exit + agentProcess.onExit((code, signal) => { + console.log(`[Conductor] Agent for session ${sessionId} exited (code: ${code}, signal: ${signal})`) + this.sessions.delete(sessionId) + }) + + // Store in sessions map + this.sessions.set(sessionId, { + agentProcess, + connection, + agentConfig: config, + agentSessionId: acpResult.sessionId, + }) + + return { connection, agentSessionId: acpResult.sessionId } + } + + /** + * Stop a session's agent process + */ + async stopSession(sessionId: string): Promise { + const sessionAgent = this.sessions.get(sessionId) + if (sessionAgent) { + console.log(`[Conductor] Stopping session ${sessionId} agent: ${sessionAgent.agentConfig.name}`) + await sessionAgent.agentProcess.stop() + this.sessions.delete(sessionId) + console.log(`[Conductor] Session ${sessionId} agent stopped`) + } + } + + /** + * Stop all session agents + */ + async stopAllSessions(): Promise { + const sessionIds = Array.from(this.sessions.keys()) + for (const sessionId of sessionIds) { + await this.stopSession(sessionId) + } + } + + /** + * Create a new session with a new agent process + */ + async createSession(cwd: string, agentConfig: AgentConfig): Promise { let session: MulticaSession if (this.sessionStore) { - // Persist session + // Create session record first to get the ID session = await this.sessionStore.create({ - agentSessionId: result.sessionId, - agentId: this.currentAgentConfig.id, + agentSessionId: '', // Will be updated after agent starts + agentId: agentConfig.id, workingDirectory: cwd, }) } else { @@ -163,8 +186,8 @@ export class Conductor { const now = new Date().toISOString() session = { id: randomUUID(), - agentSessionId: result.sessionId, - agentId: this.currentAgentConfig.id, + agentSessionId: '', + agentId: agentConfig.id, workingDirectory: cwd, createdAt: now, updatedAt: now, @@ -174,14 +197,24 @@ export class Conductor { this.inMemorySession = session } - this.activeSessionId = session.id - console.log(`[Conductor] Created session: ${session.id} (agent: ${result.sessionId})`) + // Start agent process for this session + const { agentSessionId } = await this.startAgentForSession(session.id, agentConfig, cwd) + + // Update session with agentSessionId + if (this.sessionStore) { + session = await this.sessionStore.updateMeta(session.id, { agentSessionId }) + } else { + session.agentSessionId = agentSessionId + this.inMemorySession = session + } + + console.log(`[Conductor] Created session: ${session.id} (agent: ${agentSessionId})`) return session } /** - * Resume an existing session (for UI display only, agent state is not restored) + * Resume an existing session (starts a new agent process for it) */ async resumeSession(sessionId: string): Promise { if (!this.sessionStore) { @@ -193,30 +226,32 @@ export class Conductor { throw new Error(`Session not found: ${sessionId}`) } - // Ensure agent is started + // If session already has a running agent, return as-is + if (this.sessions.has(sessionId)) { + console.log(`[Conductor] Session ${sessionId} already has a running agent`) + return data.session + } + + // Get agent config const agentConfig = DEFAULT_AGENTS[data.session.agentId] if (!agentConfig) { throw new Error(`Unknown agent: ${data.session.agentId}`) } - if (!this.isAgentRunning() || this.currentAgentConfig?.id !== agentConfig.id) { - await this.startAgent(agentConfig) - } - - // Create new ACP session (agent doesn't know about previous conversation) - const result = await this.connection!.newSession({ - cwd: data.session.workingDirectory, - mcpServers: [], - }) + // Start a new agent process for this session + const { agentSessionId } = await this.startAgentForSession( + sessionId, + agentConfig, + data.session.workingDirectory + ) // Update agentSessionId (new ACP session) const updatedSession = await this.sessionStore.updateMeta(sessionId, { - agentSessionId: result.sessionId, + agentSessionId, status: 'active', }) - this.activeSessionId = sessionId - console.log(`[Conductor] Resumed session: ${sessionId} (new agent session: ${result.sessionId})`) + console.log(`[Conductor] Resumed session: ${sessionId} (new agent session: ${agentSessionId})`) return updatedSession } @@ -225,30 +260,15 @@ export class Conductor { * Send a prompt to the agent */ async sendPrompt(sessionId: string, content: string): Promise { - if (!this.connection) { - throw new Error('No agent is running') - } - - // Get agentSessionId - let agentSessionId: string - - if (this.sessionStore) { - const data = await this.sessionStore.get(sessionId) - if (!data) { - throw new Error(`Session not found: ${sessionId}`) - } - agentSessionId = data.session.agentSessionId - } else if (this.inMemorySession && this.inMemorySession.id === sessionId) { - agentSessionId = this.inMemorySession.agentSessionId - } else { - throw new Error(`Session not found: ${sessionId}`) - } + // Ensure agent is running (lazy start if needed) + const sessionAgent = await this.ensureAgentForSession(sessionId) + const { connection, agentSessionId } = sessionAgent console.log(`[Conductor] Sending prompt to session ${agentSessionId}`) console.log(`[Conductor] Content: ${content.slice(0, 100)}${content.length > 100 ? '...' : ''}`) // Store user message before sending (so it appears in history) - if (this.sessionStore && this.activeSessionId) { + if (this.sessionStore) { const userUpdate = { sessionId: agentSessionId, update: { @@ -256,44 +276,42 @@ export class Conductor { content: { type: 'text', text: content }, }, } - await this.sessionStore.appendUpdate(this.activeSessionId, userUpdate as any) + await this.sessionStore.appendUpdate(sessionId, userUpdate as any) } - const result = await this.connection.prompt({ - sessionId: agentSessionId, - prompt: [{ type: 'text', text: content }], - }) + // Mark session as processing and broadcast status change + this.processingSessions.add(sessionId) + this.events.onStatusChange?.() - console.log(`[Conductor] Prompt completed with stopReason: ${result.stopReason}`) + try { + const result = await connection.prompt({ + sessionId: agentSessionId, + prompt: [{ type: 'text', text: content }], + }) - return result.stopReason + console.log(`[Conductor] Prompt completed with stopReason: ${result.stopReason}`) + + return result.stopReason + } finally { + // Always remove from processing when done (success or error) + this.processingSessions.delete(sessionId) + this.events.onStatusChange?.() + } } /** * Cancel an ongoing request */ async cancelRequest(sessionId: string): Promise { - if (!this.connection) { + const sessionAgent = this.sessions.get(sessionId) + if (!sessionAgent) { return } - // Get agentSessionId - let agentSessionId: string | null = null - - if (this.sessionStore) { - const data = await this.sessionStore.get(sessionId) - if (data) { - agentSessionId = data.session.agentSessionId - } - } else if (this.inMemorySession && this.inMemorySession.id === sessionId) { - agentSessionId = this.inMemorySession.agentSessionId - } - - if (agentSessionId) { - console.log(`[Conductor] Cancelling request for session ${agentSessionId}`) - await this.connection.cancel({ sessionId: agentSessionId }) - console.log(`[Conductor] Cancel request sent`) - } + const { connection, agentSessionId } = sessionAgent + console.log(`[Conductor] Cancelling request for session ${agentSessionId}`) + await connection.cancel({ sessionId: agentSessionId }) + console.log(`[Conductor] Cancel request sent`) } /** @@ -316,16 +334,77 @@ export class Conductor { return this.sessionStore.get(sessionId) } + /** + * Load a session without starting its agent (lazy loading) + */ + async loadSession(sessionId: string): Promise { + if (!this.sessionStore) { + throw new Error('Session loading not available in CLI mode') + } + + const data = await this.sessionStore.get(sessionId) + if (!data) { + throw new Error(`Session not found: ${sessionId}`) + } + + return data.session + } + + /** + * Ensure an agent is running for a session (start if needed) + */ + private async ensureAgentForSession(sessionId: string): Promise { + // If already running, return existing + const existing = this.sessions.get(sessionId) + if (existing) { + return existing + } + + // Load session data + if (!this.sessionStore) { + throw new Error('Cannot auto-start agent in CLI mode') + } + + const data = await this.sessionStore.get(sessionId) + if (!data) { + throw new Error(`Session not found: ${sessionId}`) + } + + // Get agent config + const agentConfig = DEFAULT_AGENTS[data.session.agentId] + if (!agentConfig) { + throw new Error(`Unknown agent: ${data.session.agentId}`) + } + + console.log(`[Conductor] Lazy-starting agent for session ${sessionId}`) + + // Start agent + const { agentSessionId } = await this.startAgentForSession( + sessionId, + agentConfig, + data.session.workingDirectory + ) + + // Update session with new agentSessionId + await this.sessionStore.updateMeta(sessionId, { + agentSessionId, + status: 'active', + }) + + return this.sessions.get(sessionId)! + } + /** * Delete a session */ async deleteSession(sessionId: string): Promise { + // Stop the session's agent process first + await this.stopSession(sessionId) + + // Delete from store if (this.sessionStore) { await this.sessionStore.delete(sessionId) } - if (this.activeSessionId === sessionId) { - this.activeSessionId = null - } if (this.inMemorySession?.id === sessionId) { this.inMemorySession = null } @@ -345,30 +424,38 @@ export class Conductor { } /** - * Get current agent info + * Get agent config for a session */ - getCurrentAgent(): AgentConfig | null { - return this.currentAgentConfig + getSessionAgent(sessionId: string): AgentConfig | null { + return this.sessions.get(sessionId)?.agentConfig ?? null } /** - * Check if an agent is running + * Check if a session has a running agent */ - isAgentRunning(): boolean { - return this.agentProcess?.isRunning() ?? false + isSessionRunning(sessionId: string): boolean { + const sessionAgent = this.sessions.get(sessionId) + return sessionAgent?.agentProcess.isRunning() ?? false } /** - * Get active session ID + * Get all running session IDs (sessions with agent process running) */ - getActiveSessionId(): string | null { - return this.activeSessionId + getRunningSessionIds(): string[] { + return Array.from(this.sessions.keys()) + } + + /** + * Get all processing session IDs (sessions currently handling a request) + */ + getProcessingSessionIds(): string[] { + return Array.from(this.processingSessions) } /** * Create the Client implementation for ACP SDK */ - private createClient(): Client { + private createClient(sessionId: string): Client { return { // Handle session updates from agent sessionUpdate: async (params: SessionNotification) => { @@ -377,24 +464,23 @@ export class Conductor { if ('sessionUpdate' in update) { const updateType = update.sessionUpdate if (updateType === 'agent_message_chunk') { - // Don't log full text chunks, just note they're arriving const contentType = update.content?.type || 'unknown' - console.log(`[ACP] Session update: ${updateType} (${contentType})`) + console.log(`[ACP] Session ${sessionId} update: ${updateType} (${contentType})`) } else if (updateType === 'tool_call') { - console.log(`[ACP] Session update: ${updateType} - ${update.title} [${update.status}]`) + console.log(`[ACP] Session ${sessionId} update: ${updateType} - ${update.title} [${update.status}]`) } else if (updateType === 'tool_call_update') { - console.log(`[ACP] Session update: ${updateType} [${update.status}]`) + console.log(`[ACP] Session ${sessionId} update: ${updateType} [${update.status}]`) } else { - console.log(`[ACP] Session update: ${updateType}`, update) + console.log(`[ACP] Session ${sessionId} update: ${updateType}`, update) } } else { - console.log(`[ACP] Session update (raw):`, params) + console.log(`[ACP] Session ${sessionId} update (raw):`, params) } // Store raw update to SessionStore (if available) - if (this.activeSessionId && this.sessionStore) { + if (this.sessionStore) { try { - await this.sessionStore.appendUpdate(this.activeSessionId, params) + await this.sessionStore.appendUpdate(sessionId, params) } catch (err) { console.error('[Conductor] Failed to store session update:', err) } @@ -414,7 +500,6 @@ export class Conductor { return this.events.onPermissionRequest(params) } // Default: auto-approve (V1 simplification) - // In production, this should prompt the user console.log(`[Conductor] Auto-approving: ${params.toolCall.title}`) return { outcome: { diff --git a/src/main/index.ts b/src/main/index.ts index bca9188026..80d175f3b5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -77,6 +77,17 @@ app.whenReady().then(async () => { }) } }, + onStatusChange: () => { + // Broadcast status change to renderer (for isProcessing state) + if (mainWindow && !mainWindow.isDestroyed()) { + const status = { + runningSessions: conductor.getRunningSessionIds().length, + sessionIds: conductor.getRunningSessionIds(), + processingSessionIds: conductor.getProcessingSessionIds(), + } + mainWindow.webContents.send(IPC_CHANNELS.AGENT_STATUS, status) + } + }, }, }) await conductor.initialize() diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index c050f68f78..b49da14eb0 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -10,30 +10,7 @@ import type { Conductor } from '../conductor/Conductor' import type { ListSessionsOptions, MulticaSession } from '../../shared/types' export function registerIPCHandlers(conductor: Conductor): void { - // --- Agent handlers --- - - ipcMain.handle(IPC_CHANNELS.AGENT_START, async (_event, agentId: string) => { - const config = DEFAULT_AGENTS[agentId] - if (!config) { - throw new Error(`Unknown agent: ${agentId}`) - } - await conductor.startAgent(config) - return { success: true, agentId } - }) - - ipcMain.handle(IPC_CHANNELS.AGENT_STOP, async () => { - await conductor.stopAgent() - return { success: true } - }) - - ipcMain.handle(IPC_CHANNELS.AGENT_SWITCH, async (_event, agentId: string) => { - const config = DEFAULT_AGENTS[agentId] - if (!config) { - throw new Error(`Unknown agent: ${agentId}`) - } - await conductor.startAgent(config) - return { success: true, agentId } - }) + // --- Agent handlers (per-session) --- ipcMain.handle(IPC_CHANNELS.AGENT_PROMPT, async (_event, sessionId: string, content: string) => { const stopReason = await conductor.sendPrompt(sessionId, content) @@ -46,23 +23,28 @@ export function registerIPCHandlers(conductor: Conductor): void { }) ipcMain.handle(IPC_CHANNELS.AGENT_STATUS, async () => { - const agent = conductor.getCurrentAgent() - const isRunning = conductor.isAgentRunning() - if (!isRunning || !agent) { - return { state: 'stopped' as const } - } + // Return status of all running sessions + const runningSessionIds = conductor.getRunningSessionIds() + const processingSessionIds = conductor.getProcessingSessionIds() return { - state: 'running' as const, - agentId: agent.id, - sessionCount: 0, // TODO: track session count + runningSessions: runningSessionIds.length, + sessionIds: runningSessionIds, + processingSessionIds, } }) // --- Session handlers --- - ipcMain.handle(IPC_CHANNELS.SESSION_CREATE, async (_event, workingDirectory: string) => { - return conductor.createSession(workingDirectory) - }) + ipcMain.handle( + IPC_CHANNELS.SESSION_CREATE, + async (_event, workingDirectory: string, agentId: string) => { + const config = DEFAULT_AGENTS[agentId] + if (!config) { + throw new Error(`Unknown agent: ${agentId}`) + } + return conductor.createSession(workingDirectory, config) + } + ) ipcMain.handle(IPC_CHANNELS.SESSION_LIST, async (_event, options?: ListSessionsOptions) => { return conductor.listSessions(options) @@ -72,6 +54,10 @@ export function registerIPCHandlers(conductor: Conductor): void { return conductor.getSessionData(sessionId) }) + ipcMain.handle(IPC_CHANNELS.SESSION_LOAD, async (_event, sessionId: string) => { + return conductor.loadSession(sessionId) + }) + ipcMain.handle(IPC_CHANNELS.SESSION_RESUME, async (_event, sessionId: string) => { return conductor.resumeSession(sessionId) }) @@ -93,7 +79,7 @@ export function registerIPCHandlers(conductor: Conductor): void { ipcMain.handle(IPC_CHANNELS.CONFIG_GET, async () => { return { version: '0.1.0', - activeAgentId: conductor.getCurrentAgent()?.id ?? 'opencode', + defaultAgentId: 'opencode', // Default agent for new sessions agents: DEFAULT_AGENTS, ui: { theme: 'system', diff --git a/src/preload/index.ts b/src/preload/index.ts index 055fae4cc3..6bac387756 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,13 +5,7 @@ import type { ListSessionsOptions, MulticaSession } from '../shared/types' // Electron API exposed to renderer process const electronAPI: ElectronAPI = { - // Agent lifecycle - startAgent: (agentId: string) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_START, agentId), - - stopAgent: () => ipcRenderer.invoke(IPC_CHANNELS.AGENT_STOP), - - switchAgent: (agentId: string) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_SWITCH, agentId), - + // Agent status (per-session agents) getAgentStatus: () => ipcRenderer.invoke(IPC_CHANNELS.AGENT_STATUS), // Agent communication @@ -20,15 +14,17 @@ const electronAPI: ElectronAPI = { cancelRequest: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_CANCEL, sessionId), - // Session management - createSession: (workingDirectory: string) => - ipcRenderer.invoke(IPC_CHANNELS.SESSION_CREATE, workingDirectory), + // Session management (agent starts when session is created) + createSession: (workingDirectory: string, agentId: string) => + ipcRenderer.invoke(IPC_CHANNELS.SESSION_CREATE, workingDirectory, agentId), listSessions: (options?: ListSessionsOptions) => ipcRenderer.invoke(IPC_CHANNELS.SESSION_LIST, options), getSession: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.SESSION_GET, sessionId), + loadSession: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.SESSION_LOAD, sessionId), + resumeSession: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.SESSION_RESUME, sessionId), deleteSession: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.SESSION_DELETE, sessionId), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 97e33513a7..c71c20251d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -23,7 +23,7 @@ function AppContent(): React.JSX.Element { sessions, currentSession, sessionUpdates, - agentStatus, + runningSessionsStatus, isProcessing, error, @@ -31,9 +31,6 @@ function AppContent(): React.JSX.Element { createSession, selectSession, deleteSession, - startAgent, - stopAgent, - switchAgent, sendPrompt, cancelRequest, clearError, @@ -42,17 +39,18 @@ function AppContent(): React.JSX.Element { // New session dialog state const [showNewSession, setShowNewSession] = useState(false) const [newSessionCwd, setNewSessionCwd] = useState('') + const [selectedAgentId, setSelectedAgentId] = useState('opencode') // Settings dialog state const [showSettings, setShowSettings] = useState(false) - // Auto-show new session dialog when agent is running but no session + // Auto-show new session dialog when no sessions exist useEffect(() => { - if (agentStatus.state === 'running' && !currentSession && sessions.length === 0) { + if (!currentSession && sessions.length === 0) { setNewSessionCwd('') setShowNewSession(true) } - }, [agentStatus.state, currentSession, sessions.length]) + }, [currentSession, sessions.length]) const handleNewSession = () => { setNewSessionCwd('') @@ -62,27 +60,22 @@ function AppContent(): React.JSX.Element { const handleCreateSession = async () => { if (!newSessionCwd.trim()) return - // Ensure agent is running - if (agentStatus.state !== 'running') { - await startAgent('opencode') - } - - await createSession(newSessionCwd.trim()) + // Create session with selected agent (agent starts automatically) + await createSession(newSessionCwd.trim(), selectedAgentId) setShowNewSession(false) setNewSessionCwd('') } const handleSelectSession = async (sessionId: string) => { - // Ensure agent is running - if (agentStatus.state !== 'running') { - const session = sessions.find((s) => s.id === sessionId) - if (session) { - await startAgent(session.agentId) - } - } + // Select session (agent starts automatically via resumeSession) await selectSession(sessionId) } + // Check if current session has a running agent + const isCurrentSessionRunning = currentSession + ? runningSessionsStatus.sessionIds.includes(currentSession.id) + : false + return (
{/* Error banner */} @@ -110,10 +103,9 @@ function AppContent(): React.JSX.Element {
{/* Status bar */} startAgent('opencode')} - onStopAgent={stopAgent} + isCurrentSessionRunning={isCurrentSessionRunning} onOpenSettings={() => setShowSettings(true)} /> @@ -130,7 +122,7 @@ function AppContent(): React.JSX.Element { onSend={sendPrompt} onCancel={cancelRequest} isProcessing={isProcessing} - disabled={!currentSession || agentStatus.state !== 'running'} + disabled={!currentSession} />
@@ -184,8 +176,8 @@ function AppContent(): React.JSX.Element { setShowSettings(false)} - currentAgentId={agentStatus.state === 'running' ? agentStatus.agentId : null} - onSwitchAgent={switchAgent} + defaultAgentId={selectedAgentId} + onSetDefaultAgent={setSelectedAgentId} />
) diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index 9da52a038c..87dd5eecc6 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -21,8 +21,8 @@ import { Sun, Moon, Monitor, Check, Loader2, RefreshCw } from 'lucide-react' interface SettingsProps { isOpen: boolean onClose: () => void - currentAgentId: string | null - onSwitchAgent: (agentId: string) => Promise + defaultAgentId: string + onSetDefaultAgent: (agentId: string) => void } // Agent icons mapping @@ -35,28 +35,18 @@ const AGENT_ICONS: Record = { type ThemeMode = 'light' | 'dark' | 'system' -export function Settings({ isOpen, onClose, currentAgentId, onSwitchAgent }: SettingsProps) { +export function Settings({ isOpen, onClose, defaultAgentId, onSetDefaultAgent }: SettingsProps) { const [agents, setAgents] = useState([]) const [loading, setLoading] = useState(true) - const [switching, setSwitching] = useState(null) - const [selectedAgent, setSelectedAgent] = useState(null) + const [selectedAgent, setSelectedAgent] = useState(defaultAgentId) const { mode, setMode } = useTheme() useEffect(() => { if (isOpen) { - setSelectedAgent(currentAgentId) + setSelectedAgent(defaultAgentId) loadAgents() } - }, [isOpen, currentAgentId]) - - useEffect(() => { - if (agents.length > 0 && !selectedAgent) { - const firstInstalled = agents.find(a => a.installed) - if (firstInstalled) { - setSelectedAgent(firstInstalled.id) - } - } - }, [agents, selectedAgent]) + }, [isOpen, defaultAgentId]) async function loadAgents() { setLoading(true) @@ -70,23 +60,11 @@ export function Settings({ isOpen, onClose, currentAgentId, onSwitchAgent }: Set } } - async function handleContinue() { - if (!selectedAgent || switching) return - - if (selectedAgent === currentAgentId) { - onClose() - return - } - - setSwitching(selectedAgent) - try { - await onSwitchAgent(selectedAgent) - onClose() - } catch (err) { - console.error('Failed to switch agent:', err) - } finally { - setSwitching(null) + function handleDone() { + if (selectedAgent !== defaultAgentId) { + onSetDefaultAgent(selectedAgent) } + onClose() } const installedCount = agents.filter(a => a.installed).length @@ -124,9 +102,9 @@ export function Settings({ isOpen, onClose, currentAgentId, onSwitchAgent }: Set {/* Agent Section */}
-

Coding Agent

+

Default Agent

- Select a coding agent. Agents use local authentication. + Select the default agent for new sessions. Each session runs its own agent process.

{loading ? ( @@ -164,17 +142,10 @@ export function Settings({ isOpen, onClose, currentAgentId, onSwitchAgent }: Set Cancel
diff --git a/src/renderer/src/components/StatusBar.tsx b/src/renderer/src/components/StatusBar.tsx index ac4e4bdc4a..97f36be369 100644 --- a/src/renderer/src/components/StatusBar.tsx +++ b/src/renderer/src/components/StatusBar.tsx @@ -1,25 +1,23 @@ /** - * Status bar component - shows agent status and current session info + * Status bar component - shows session info and running status */ -import type { AgentStatus, MulticaSession } from '../../../shared/types' +import type { MulticaSession } from '../../../shared/types' import { Button } from '@/components/ui/button' import { SidebarTrigger, useSidebar } from '@/components/ui/sidebar' import { cn } from '@/lib/utils' import { Settings } from 'lucide-react' interface StatusBarProps { - agentStatus: AgentStatus + runningSessionsCount: number currentSession: MulticaSession | null - onStartAgent: () => void - onStopAgent: () => void + isCurrentSessionRunning: boolean onOpenSettings: () => void } export function StatusBar({ - agentStatus, + runningSessionsCount, currentSession, - onStartAgent, - onStopAgent, + isCurrentSessionRunning, onOpenSettings, }: StatusBarProps) { const { state, isMobile } = useSidebar() @@ -49,19 +47,12 @@ export function StatusBar({ )} - {/* Right: Agent status */} + {/* Right: Status + Settings */}
- - - {agentStatus.state === 'stopped' ? ( - - ) : agentStatus.state === 'running' ? ( - - ) : null} +