mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 10:08:38 +02:00
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:
104
src/main/cli.ts
104
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 <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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
20
src/shared/electron-api.d.ts
vendored
20
src/shared/electron-api.d.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user