Merge pull request #2 from multica-ai/feature/per-session-agent

refactor: make each session run its own agent process
This commit is contained in:
LinYushen
2026-01-14 18:25:38 +08:00
committed by GitHub
11 changed files with 412 additions and 444 deletions

View File

@@ -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 <id>${c.reset} Resume an existing session
${c.cyan}/delete <id>${c.reset} Delete a session
${c.cyan}/history${c.reset} Show current session message history
${c.cyan}/agent <name>${c.reset} Switch to a different agent
${c.cyan}/default <name>${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<AgentCheckResult[]> {
return results
}
async function cmdSwitchAgent(state: CLIState, agentId: string) {
async function cmdSetDefaultAgent(state: CLIState, agentId: string) {
if (!agentId) {
printError('Usage: /agent <name>')
printError('Usage: /default <name>')
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,

View File

@@ -29,6 +29,7 @@ export interface ConductorEvents {
onPermissionRequest?: (
params: RequestPermissionRequest
) => Promise<RequestPermissionResponse>
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<string, SessionAgent> = new Map()
// Set of session IDs currently processing a request
private processingSessions: Set<string> = 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<void> {
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<void> {
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<MulticaSession> {
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<void> {
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<void> {
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<MulticaSession> {
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<MulticaSession> {
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<string> {
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<void> {
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<MulticaSession> {
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<SessionAgent> {
// 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<void> {
// 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: {

View File

@@ -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()

View File

@@ -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',

View File

@@ -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),

View File

@@ -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 (
<div className="flex h-screen flex-col bg-[var(--color-background)] text-[var(--color-text)]">
{/* Error banner */}
@@ -110,10 +103,9 @@ function AppContent(): React.JSX.Element {
<main className="flex flex-1 flex-col">
{/* Status bar */}
<StatusBar
agentStatus={agentStatus}
runningSessionsCount={runningSessionsStatus.runningSessions}
currentSession={currentSession}
onStartAgent={() => 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}
/>
</main>
</SidebarProvider>
@@ -184,8 +176,8 @@ function AppContent(): React.JSX.Element {
<Settings
isOpen={showSettings}
onClose={() => setShowSettings(false)}
currentAgentId={agentStatus.state === 'running' ? agentStatus.agentId : null}
onSwitchAgent={switchAgent}
defaultAgentId={selectedAgentId}
onSetDefaultAgent={setSelectedAgentId}
/>
</div>
)

View File

@@ -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<void>
defaultAgentId: string
onSetDefaultAgent: (agentId: string) => void
}
// Agent icons mapping
@@ -35,28 +35,18 @@ const AGENT_ICONS: Record<string, string> = {
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<AgentCheckResult[]>([])
const [loading, setLoading] = useState(true)
const [switching, setSwitching] = useState<string | null>(null)
const [selectedAgent, setSelectedAgent] = useState<string | null>(null)
const [selectedAgent, setSelectedAgent] = useState<string>(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 */}
<div className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground">Coding Agent</h2>
<h2 className="text-sm font-medium text-muted-foreground">Default Agent</h2>
<p className="text-xs text-muted-foreground">
Select a coding agent. Agents use local authentication.
Select the default agent for new sessions. Each session runs its own agent process.
</p>
{loading ? (
@@ -164,17 +142,10 @@ export function Settings({ isOpen, onClose, currentAgentId, onSwitchAgent }: Set
Cancel
</Button>
<Button
onClick={handleContinue}
disabled={!selectedAgent || !!switching || installedCount === 0}
onClick={handleDone}
disabled={!selectedAgent || installedCount === 0}
>
{switching ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Switching...
</>
) : (
'Done'
)}
Done
</Button>
</div>
</DialogFooter>

View File

@@ -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({
)}
</div>
{/* Right: Agent status */}
{/* Right: Status + Settings */}
<div className="titlebar-no-drag flex items-center gap-3">
<AgentStatusBadge status={agentStatus} />
{agentStatus.state === 'stopped' ? (
<Button size="sm" onClick={onStartAgent}>
Start Agent
</Button>
) : agentStatus.state === 'running' ? (
<Button size="sm" variant="secondary" onClick={onStopAgent}>
Stop
</Button>
) : null}
<SessionStatusBadge
isRunning={isCurrentSessionRunning}
runningCount={runningSessionsCount}
/>
<Button variant="ghost" size="icon-sm" onClick={onOpenSettings} title="Settings">
<Settings className="h-4 w-4" />
@@ -71,32 +62,18 @@ export function StatusBar({
)
}
interface AgentStatusBadgeProps {
status: AgentStatus
interface SessionStatusBadgeProps {
isRunning: boolean
runningCount: number
}
function AgentStatusBadge({ status }: AgentStatusBadgeProps) {
let dotColor = 'bg-gray-500'
let text = 'Stopped'
switch (status.state) {
case 'starting':
dotColor = 'bg-yellow-500 animate-pulse'
text = `Starting ${status.agentId}...`
break
case 'running':
dotColor = 'bg-green-500'
text = status.agentId
break
case 'error':
dotColor = 'bg-red-500'
text = 'Error'
break
case 'stopped':
dotColor = 'bg-gray-500'
text = 'Stopped'
break
}
function SessionStatusBadge({ isRunning, runningCount }: SessionStatusBadgeProps) {
const dotColor = isRunning ? 'bg-green-500' : 'bg-gray-500'
const text = isRunning
? `Running (${runningCount} session${runningCount !== 1 ? 's' : ''})`
: runningCount > 0
? `${runningCount} running`
: 'No sessions'
return (
<div className="flex items-center gap-2">

View File

@@ -4,9 +4,9 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import type {
MulticaSession,
AgentStatus,
StoredSessionUpdate,
} from '../../../shared/types'
import type { RunningSessionsStatus } from '../../../shared/electron-api'
export interface AppState {
// Sessions
@@ -14,8 +14,8 @@ export interface AppState {
currentSession: MulticaSession | null
sessionUpdates: StoredSessionUpdate[]
// Agent
agentStatus: AgentStatus
// Agent (per-session)
runningSessionsStatus: RunningSessionsStatus
isProcessing: boolean
// UI
@@ -25,14 +25,11 @@ export interface AppState {
export interface AppActions {
// Session actions
loadSessions: () => Promise<void>
createSession: (cwd: string) => Promise<void>
createSession: (cwd: string, agentId: string) => Promise<void>
selectSession: (sessionId: string) => Promise<void>
deleteSession: (sessionId: string) => Promise<void>
// Agent actions
startAgent: (agentId: string) => Promise<void>
stopAgent: () => Promise<void>
switchAgent: (agentId: string) => Promise<void>
// Agent actions (per-session)
sendPrompt: (content: string) => Promise<void>
cancelRequest: () => Promise<void>
@@ -45,22 +42,36 @@ export function useApp(): AppState & AppActions {
const [sessions, setSessions] = useState<MulticaSession[]>([])
const [currentSession, setCurrentSession] = useState<MulticaSession | null>(null)
const [sessionUpdates, setSessionUpdates] = useState<StoredSessionUpdate[]>([])
const [agentStatus, setAgentStatus] = useState<AgentStatus>({ state: 'stopped' })
const [isProcessing, setIsProcessing] = useState(false)
const [runningSessionsStatus, setRunningSessionsStatus] = useState<RunningSessionsStatus>({
runningSessions: 0,
sessionIds: [],
processingSessionIds: [],
})
const [error, setError] = useState<string | null>(null)
// Derive isProcessing from processingSessionIds (per-session isolation)
const isProcessing = currentSession
? runningSessionsStatus.processingSessionIds.includes(currentSession.id)
: false
// Track streaming text for current message
const streamingTextRef = useRef<string>('')
// Load sessions on mount
useEffect(() => {
loadSessions()
loadAgentStatus()
loadRunningStatus()
}, [])
// Subscribe to agent events
useEffect(() => {
const unsubMessage = window.electronAPI.onAgentMessage((message) => {
// Only process messages for the current session
// message.sessionId is ACP Agent Session ID, compare with currentSession.agentSessionId
if (!currentSession || message.sessionId !== currentSession.agentSessionId) {
return
}
const update = message.update
const updateType = update?.sessionUpdate
@@ -114,17 +125,15 @@ export function useApp(): AppState & AppActions {
if (message.done) {
streamingTextRef.current = ''
setIsProcessing(false)
}
})
const unsubStatus = window.electronAPI.onAgentStatus((status) => {
setAgentStatus(status)
setRunningSessionsStatus(status)
})
const unsubError = window.electronAPI.onAgentError((err) => {
setError(err.message)
setIsProcessing(false)
})
return () => {
@@ -132,7 +141,7 @@ export function useApp(): AppState & AppActions {
unsubStatus()
unsubError()
}
}, [])
}, [currentSession])
// Actions
const loadSessions = useCallback(async () => {
@@ -144,32 +153,33 @@ export function useApp(): AppState & AppActions {
}
}, [])
const loadAgentStatus = useCallback(async () => {
const loadRunningStatus = useCallback(async () => {
try {
const status = await window.electronAPI.getAgentStatus()
setAgentStatus(status)
setRunningSessionsStatus(status)
} catch (err) {
console.error('Failed to get agent status:', err)
console.error('Failed to get running status:', err)
}
}, [])
const createSession = useCallback(async (cwd: string) => {
const createSession = useCallback(async (cwd: string, agentId: string) => {
try {
setError(null)
const session = await window.electronAPI.createSession(cwd)
const session = await window.electronAPI.createSession(cwd, agentId)
setCurrentSession(session)
setSessionUpdates([])
await loadSessions()
await loadRunningStatus()
} catch (err) {
setError(`Failed to create session: ${err}`)
}
}, [loadSessions])
}, [loadSessions, loadRunningStatus])
const selectSession = useCallback(async (sessionId: string) => {
try {
setError(null)
// Resume session (creates new ACP session, loads history)
const session = await window.electronAPI.resumeSession(sessionId)
// Load session without starting agent (lazy loading)
const session = await window.electronAPI.loadSession(sessionId)
setCurrentSession(session)
// Load session data for history
@@ -191,46 +201,11 @@ export function useApp(): AppState & AppActions {
setSessionUpdates([])
}
await loadSessions()
await loadRunningStatus()
} catch (err) {
setError(`Failed to delete session: ${err}`)
}
}, [currentSession, loadSessions])
const startAgent = useCallback(async (agentId: string) => {
try {
setError(null)
await window.electronAPI.startAgent(agentId)
await loadAgentStatus()
} catch (err) {
setError(`Failed to start agent: ${err}`)
}
}, [loadAgentStatus])
const stopAgent = useCallback(async () => {
try {
setError(null)
await window.electronAPI.stopAgent()
await loadAgentStatus()
} catch (err) {
setError(`Failed to stop agent: ${err}`)
}
}, [loadAgentStatus])
const switchAgent = useCallback(async (agentId: string) => {
try {
setError(null)
// Stop current agent first
await window.electronAPI.stopAgent()
// Clear current session (it's bound to the old agent)
setCurrentSession(null)
setSessionUpdates([])
// Start new agent
await window.electronAPI.startAgent(agentId)
await loadAgentStatus()
} catch (err) {
setError(`Failed to switch agent: ${err}`)
}
}, [loadAgentStatus])
}, [currentSession, loadSessions, loadRunningStatus])
const sendPrompt = useCallback(async (content: string) => {
if (!currentSession) {
@@ -240,7 +215,6 @@ export function useApp(): AppState & AppActions {
try {
setError(null)
setIsProcessing(true)
streamingTextRef.current = ''
// Add user message to updates (use a custom marker for UI display)
@@ -258,10 +232,8 @@ export function useApp(): AppState & AppActions {
setSessionUpdates((prev) => [...prev, userUpdate])
await window.electronAPI.sendPrompt(currentSession.id, content)
setIsProcessing(false)
} catch (err) {
setError(`Failed to send prompt: ${err}`)
setIsProcessing(false)
}
}, [currentSession])
@@ -270,7 +242,6 @@ export function useApp(): AppState & AppActions {
try {
await window.electronAPI.cancelRequest(currentSession.id)
setIsProcessing(false)
} catch (err) {
setError(`Failed to cancel: ${err}`)
}
@@ -285,7 +256,7 @@ export function useApp(): AppState & AppActions {
sessions,
currentSession,
sessionUpdates,
agentStatus,
runningSessionsStatus,
isProcessing,
error,
@@ -294,9 +265,6 @@ export function useApp(): AppState & AppActions {
createSession,
selectSession,
deleteSession,
startAgent,
stopAgent,
switchAgent,
sendPrompt,
cancelRequest,
clearError,

View File

@@ -35,21 +35,25 @@ export interface AgentCheckResult {
installHint?: string
}
export interface RunningSessionsStatus {
runningSessions: number
sessionIds: string[]
processingSessionIds: string[] // Sessions currently handling a request
}
export interface ElectronAPI {
// Agent lifecycle
startAgent(agentId: string): Promise<{ success: boolean; agentId: string }>
stopAgent(): Promise<{ success: boolean }>
switchAgent(agentId: string): Promise<{ success: boolean; agentId: string }>
getAgentStatus(): Promise<AgentStatus>
// Agent status (per-session agents)
getAgentStatus(): Promise<RunningSessionsStatus>
// Agent communication
sendPrompt(sessionId: string, content: string): Promise<{ stopReason: string }>
cancelRequest(sessionId: string): Promise<{ success: boolean }>
// Session management
createSession(workingDirectory: string): Promise<MulticaSession>
// Session management (agent starts when session is created)
createSession(workingDirectory: string, agentId: string): Promise<MulticaSession>
listSessions(options?: ListSessionsOptions): Promise<MulticaSession[]>
getSession(sessionId: string): Promise<SessionData | null>
loadSession(sessionId: string): Promise<MulticaSession> // Load without starting agent
resumeSession(sessionId: string): Promise<MulticaSession>
deleteSession(sessionId: string): Promise<{ success: boolean }>
updateSession(sessionId: string, updates: Partial<MulticaSession>): Promise<MulticaSession>
@@ -66,7 +70,7 @@ export interface ElectronAPI {
// Event listeners (return unsubscribe function)
onAgentMessage(callback: (message: AgentMessage) => void): () => void
onAgentStatus(callback: (status: AgentStatus) => void): () => void
onAgentStatus(callback: (status: RunningSessionsStatus) => void): () => void
onAgentError(callback: (error: Error) => void): () => void
}

View File

@@ -2,20 +2,18 @@
* IPC Channel definitions for communication between main and renderer processes
*/
export const IPC_CHANNELS = {
// Agent communication
// Agent communication (per-session)
AGENT_PROMPT: 'agent:prompt',
AGENT_CANCEL: 'agent:cancel',
AGENT_SWITCH: 'agent:switch',
AGENT_START: 'agent:start',
AGENT_STOP: 'agent:stop',
AGENT_MESSAGE: 'agent:message',
AGENT_ERROR: 'agent:error',
AGENT_STATUS: 'agent:status',
AGENT_STATUS: 'agent:status', // Returns status of all running sessions
// Session management
SESSION_CREATE: 'session:create',
SESSION_LIST: 'session:list',
SESSION_GET: 'session:get',
SESSION_LOAD: 'session:load', // Load session without starting agent (lazy)
SESSION_RESUME: 'session:resume',
SESSION_DELETE: 'session:delete',
SESSION_UPDATE: 'session:update',