610 lines
18 KiB
TypeScript
610 lines
18 KiB
TypeScript
/**
|
|
* Hub IPC handlers for Electron main process.
|
|
*
|
|
* Creates and manages a Hub instance that connects to the Gateway.
|
|
* This follows the same pattern as the Console app.
|
|
*/
|
|
import { ipcMain, type BrowserWindow } from 'electron'
|
|
import { Hub, type AsyncAgent } from '@multica/core'
|
|
import type { ConnectionState } from '@multica/sdk'
|
|
|
|
// Singleton Hub instance
|
|
let hub: Hub | null = null
|
|
let defaultAgentId: string | null = null
|
|
let mainWindowRef: BrowserWindow | null = null
|
|
|
|
// Track which agents have active IPC subscriptions (for local direct chat)
|
|
// Value is the unsubscribe function returned by agent.subscribe()
|
|
const ipcAgentSubscriptions = new Map<string, () => void>()
|
|
|
|
// Resolve gateway URL: GATEWAY_URL env > MAIN_VITE_GATEWAY_URL (.env file)
|
|
const gatewayUrl =
|
|
process.env.GATEWAY_URL || import.meta.env.MAIN_VITE_GATEWAY_URL
|
|
|
|
/**
|
|
* Safe log function that catches EPIPE errors.
|
|
* Electron main process stdout can be closed unexpectedly.
|
|
*/
|
|
function safeLog(...args: unknown[]): void {
|
|
try {
|
|
console.log(...args)
|
|
} catch {
|
|
// Ignore EPIPE errors when stdout is closed
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize Hub on app startup.
|
|
* Creates Hub and a default Agent automatically.
|
|
*/
|
|
export async function initializeHub(): Promise<void> {
|
|
if (hub) {
|
|
safeLog('[Desktop] Hub already initialized')
|
|
return
|
|
}
|
|
|
|
safeLog(`[Desktop] Initializing Hub, connecting to Gateway: ${gatewayUrl}`)
|
|
|
|
hub = new Hub(gatewayUrl)
|
|
|
|
// Create default agent if none exists
|
|
const agents = hub.listAgents()
|
|
if (agents.length === 0) {
|
|
safeLog('[Desktop] Creating default agent...')
|
|
const agent = hub.createAgent()
|
|
defaultAgentId = agent.sessionId
|
|
safeLog(`[Desktop] Default agent created: ${defaultAgentId}`)
|
|
} else {
|
|
defaultAgentId = agents[0]
|
|
safeLog(`[Desktop] Using existing agent: ${defaultAgentId}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get or create the Hub instance.
|
|
*/
|
|
function getHub(): Hub {
|
|
if (!hub) {
|
|
safeLog(`[Desktop] Creating Hub, connecting to Gateway: ${gatewayUrl}`)
|
|
hub = new Hub(gatewayUrl)
|
|
}
|
|
return hub
|
|
}
|
|
|
|
/**
|
|
* Get the default agent.
|
|
*/
|
|
export function getDefaultAgent(): AsyncAgent | null {
|
|
if (!hub || !defaultAgentId) return null
|
|
return hub.getAgent(defaultAgentId) ?? null
|
|
}
|
|
|
|
/**
|
|
* Hub info returned to renderer.
|
|
*/
|
|
export interface HubInfo {
|
|
hubId: string
|
|
url: string
|
|
connectionState: ConnectionState
|
|
agentCount: number
|
|
}
|
|
|
|
/**
|
|
* Agent info returned to renderer.
|
|
*/
|
|
export interface AgentInfo {
|
|
id: string
|
|
closed: boolean
|
|
}
|
|
|
|
/**
|
|
* Register all Hub-related IPC handlers.
|
|
*/
|
|
export function registerHubIpcHandlers(): void {
|
|
/**
|
|
* Initialize the Hub (creates singleton if not exists).
|
|
*/
|
|
ipcMain.handle('hub:init', async () => {
|
|
await initializeHub()
|
|
const h = getHub()
|
|
const defaultConversationId = defaultAgentId
|
|
? (h.getAgentMainConversationId(defaultAgentId) ?? defaultAgentId)
|
|
: null
|
|
return {
|
|
hubId: h.hubId,
|
|
url: h.url,
|
|
connectionState: h.connectionState,
|
|
defaultAgentId,
|
|
defaultConversationId,
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Get Hub status info.
|
|
*/
|
|
ipcMain.handle('hub:info', async (): Promise<HubInfo> => {
|
|
const h = getHub()
|
|
return {
|
|
hubId: h.hubId,
|
|
url: h.url,
|
|
connectionState: h.connectionState,
|
|
agentCount: h.listAgents().length,
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Get Hub status with default agent info (for home page).
|
|
*/
|
|
ipcMain.handle('hub:getStatus', async () => {
|
|
const h = getHub()
|
|
const agent = getDefaultAgent()
|
|
|
|
return {
|
|
hubId: h.hubId,
|
|
status: h.connectionState === 'connected' ? 'ready' : h.connectionState,
|
|
agentCount: h.listAgents().length,
|
|
gatewayConnected: h.connectionState === 'connected',
|
|
gatewayUrl: h.url,
|
|
defaultAgent: agent
|
|
? {
|
|
agentId: defaultAgentId ?? agent.sessionId,
|
|
status: agent.closed ? 'closed' : 'idle',
|
|
}
|
|
: null,
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Get default agent info.
|
|
*/
|
|
ipcMain.handle('hub:getAgentInfo', async () => {
|
|
const agent = getDefaultAgent()
|
|
if (!agent) {
|
|
return null
|
|
}
|
|
return {
|
|
agentId: defaultAgentId ?? agent.sessionId,
|
|
status: agent.closed ? 'closed' : 'idle',
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Reconnect Hub to a different Gateway URL.
|
|
*/
|
|
ipcMain.handle('hub:reconnect', async (_event, url: string) => {
|
|
const h = getHub()
|
|
h.reconnect(url)
|
|
return { url: h.url }
|
|
})
|
|
|
|
/**
|
|
* List all agents.
|
|
*/
|
|
ipcMain.handle('hub:listAgents', async (): Promise<AgentInfo[]> => {
|
|
const h = getHub()
|
|
const agentIds = h.listAgents()
|
|
return agentIds.map((id) => {
|
|
const agent = h.getAgent(id)
|
|
return {
|
|
id,
|
|
closed: agent?.closed ?? true,
|
|
}
|
|
})
|
|
})
|
|
|
|
/**
|
|
* List all conversations (alias of listAgents for clearer semantics).
|
|
*/
|
|
ipcMain.handle('hub:listConversations', async (): Promise<AgentInfo[]> => {
|
|
const h = getHub()
|
|
const conversationIds = h.listConversations()
|
|
return conversationIds.map((id) => {
|
|
const conversation = h.getConversation(id)
|
|
return {
|
|
id,
|
|
closed: conversation?.closed ?? true,
|
|
}
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Create a new agent.
|
|
*/
|
|
ipcMain.handle('hub:createAgent', async (_event, id?: string) => {
|
|
const h = getHub()
|
|
const agent = h.createAgent(id)
|
|
return {
|
|
id: agent.sessionId,
|
|
closed: agent.closed,
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Create a new conversation (alias of createAgent).
|
|
*/
|
|
ipcMain.handle('hub:createConversation', async (_event, id?: string) => {
|
|
const h = getHub()
|
|
const conversation = h.createConversation(id)
|
|
return {
|
|
id: conversation.sessionId,
|
|
closed: conversation.closed,
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Get a specific agent.
|
|
*/
|
|
ipcMain.handle('hub:getAgent', async (_event, id: string) => {
|
|
const h = getHub()
|
|
const agent = h.getAgent(id)
|
|
if (!agent) {
|
|
return { error: `Agent not found: ${id}` }
|
|
}
|
|
return {
|
|
id: agent.sessionId,
|
|
closed: agent.closed,
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Get a specific conversation (alias of getAgent).
|
|
*/
|
|
ipcMain.handle('hub:getConversation', async (_event, id: string) => {
|
|
const h = getHub()
|
|
const conversation = h.getConversation(id)
|
|
if (!conversation) {
|
|
return { error: `Conversation not found: ${id}` }
|
|
}
|
|
return {
|
|
id: conversation.sessionId,
|
|
closed: conversation.closed,
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Close/delete an agent.
|
|
*/
|
|
ipcMain.handle('hub:closeAgent', async (_event, id: string) => {
|
|
const h = getHub()
|
|
const result = h.closeAgent(id)
|
|
return { ok: result }
|
|
})
|
|
|
|
/**
|
|
* Close/delete a conversation (alias of closeAgent).
|
|
*/
|
|
ipcMain.handle('hub:closeConversation', async (_event, id: string) => {
|
|
const h = getHub()
|
|
const result = h.closeConversation(id)
|
|
return { ok: result }
|
|
})
|
|
|
|
/**
|
|
* Send a message to an agent (for remote clients via Gateway).
|
|
* Note: For local direct chat, use 'localChat:send' instead.
|
|
*/
|
|
ipcMain.handle('hub:sendMessage', async (_event, agentId: string, content: string, conversationId?: string) => {
|
|
const h = getHub()
|
|
const resolvedConversationId = conversationId ?? agentId
|
|
const agent = h.getAgent(resolvedConversationId)
|
|
if (!agent) {
|
|
return { error: `Conversation not found: ${resolvedConversationId}` }
|
|
}
|
|
if (agent.closed) {
|
|
return { error: `Conversation is closed: ${resolvedConversationId}` }
|
|
}
|
|
h.channelManager.clearLastRoute()
|
|
agent.write(content)
|
|
return { ok: true }
|
|
})
|
|
|
|
/**
|
|
* Subscribe to local agent events (for direct IPC chat without Gateway).
|
|
* Uses agent.subscribe() which supports multiple subscribers.
|
|
*/
|
|
ipcMain.handle('localChat:subscribe', async (_event, agentId: string) => {
|
|
const h = getHub()
|
|
const conversationId = agentId
|
|
const conversation = h.getConversation(conversationId)
|
|
if (!conversation) {
|
|
return { error: `Agent not found: ${conversationId}` }
|
|
}
|
|
if (conversation.closed) {
|
|
return { error: `Agent is closed: ${conversationId}` }
|
|
}
|
|
const logicalAgentId = h.getConversationAgentId(conversationId) ?? conversationId
|
|
|
|
// Already subscribed?
|
|
if (ipcAgentSubscriptions.has(conversationId)) {
|
|
return { ok: true, alreadySubscribed: true }
|
|
}
|
|
|
|
// Track current stream ID for message grouping
|
|
let currentStreamId: string | null = null
|
|
|
|
// Subscribe to agent events using the multi-subscriber mechanism
|
|
const unsubscribe = conversation.subscribe((event) => {
|
|
if (!mainWindowRef || mainWindowRef.isDestroyed()) {
|
|
return
|
|
}
|
|
|
|
// Compaction and error events: forward with no stream tracking
|
|
const isPassthroughEvent =
|
|
event.type === 'compaction_start' || event.type === 'compaction_end' || event.type === 'agent_error'
|
|
if (isPassthroughEvent) {
|
|
safeLog(`[IPC] Sending ${event.type} event to renderer`)
|
|
mainWindowRef.webContents.send('localChat:event', {
|
|
agentId: logicalAgentId,
|
|
conversationId,
|
|
streamId: null,
|
|
event,
|
|
})
|
|
return
|
|
}
|
|
|
|
|
|
// Filter events same as Hub.consumeAgent()
|
|
const maybeMessage = (event as { message?: { role?: string } }).message
|
|
const isAssistantMessage = maybeMessage?.role === 'assistant'
|
|
const shouldForward =
|
|
((event.type === 'message_start' || event.type === 'message_update' || event.type === 'message_end') && isAssistantMessage)
|
|
|| event.type === 'tool_execution_start'
|
|
|| event.type === 'tool_execution_update'
|
|
|| event.type === 'tool_execution_end'
|
|
|
|
if (!shouldForward) return
|
|
|
|
// Track stream ID for message grouping (extract from event.message.id, same as Hub.beginStream)
|
|
if (event.type === 'message_start') {
|
|
const msgId = (event as { message?: { id?: string } }).message?.id
|
|
currentStreamId = msgId ?? `stream-${Date.now()}`
|
|
safeLog(`[IPC] Starting stream: ${currentStreamId}`)
|
|
}
|
|
|
|
safeLog(`[IPC] Sending event to renderer: ${event.type}, streamId: ${currentStreamId}`)
|
|
mainWindowRef.webContents.send('localChat:event', {
|
|
agentId: logicalAgentId,
|
|
conversationId,
|
|
streamId: currentStreamId,
|
|
event,
|
|
})
|
|
|
|
if (event.type === 'message_end') {
|
|
safeLog(`[IPC] Ending stream: ${currentStreamId}`)
|
|
currentStreamId = null
|
|
}
|
|
})
|
|
|
|
ipcAgentSubscriptions.set(conversationId, unsubscribe)
|
|
|
|
// Register local approval handler so exec approval requests route via IPC
|
|
h.setLocalApprovalHandler(conversationId, (payload) => {
|
|
if (!mainWindowRef || mainWindowRef.isDestroyed()) return
|
|
safeLog(`[IPC] Sending approval request to renderer: ${payload.approvalId}`)
|
|
mainWindowRef.webContents.send('localChat:approval', payload)
|
|
})
|
|
|
|
safeLog(`[IPC] Local chat subscribed to conversation: ${conversationId}`)
|
|
|
|
return { ok: true }
|
|
})
|
|
|
|
/**
|
|
* Unsubscribe from local agent events.
|
|
*/
|
|
ipcMain.handle('localChat:unsubscribe', async (_event, agentId: string) => {
|
|
const unsubscribe = ipcAgentSubscriptions.get(agentId)
|
|
if (unsubscribe) {
|
|
unsubscribe()
|
|
}
|
|
ipcAgentSubscriptions.delete(agentId)
|
|
getHub().removeLocalApprovalHandler(agentId)
|
|
safeLog(`[IPC] Local chat unsubscribed from agent: ${agentId}`)
|
|
return { ok: true }
|
|
})
|
|
|
|
/**
|
|
* Get message history for local chat with pagination.
|
|
* Returns raw AgentMessageItem[] so the renderer can render content blocks,
|
|
* tool results, thinking blocks, etc. — same format as the Gateway RPC.
|
|
*
|
|
* Reads from session storage (not in-memory state) so that internal
|
|
* orchestration messages are excluded by default.
|
|
*/
|
|
ipcMain.handle('localChat:getHistory', async (
|
|
_event,
|
|
agentId: string,
|
|
options?: { offset?: number; limit?: number; conversationId?: string },
|
|
) => {
|
|
const h = getHub()
|
|
const conversationId = options?.conversationId ?? agentId
|
|
const agent = h.getAgent(conversationId)
|
|
if (!agent) {
|
|
return { messages: [], total: 0, offset: 0, limit: 0, contextWindowTokens: undefined }
|
|
}
|
|
|
|
try {
|
|
await agent.ensureInitialized()
|
|
const allMessages = agent.loadSessionMessagesForDisplay()
|
|
const contextWindowTokens = agent.getContextWindowTokens()
|
|
const total = allMessages.length
|
|
// Must match DEFAULT_MESSAGES_LIMIT from @multica/sdk/actions/rpc
|
|
const limit = options?.limit ?? 200
|
|
const offset = options?.offset ?? Math.max(0, total - limit)
|
|
const sliced = allMessages.slice(offset, offset + limit)
|
|
return { messages: sliced, total, offset, limit, contextWindowTokens }
|
|
} catch {
|
|
return { messages: [], total: 0, offset: 0, limit: 0, contextWindowTokens: undefined }
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Send a message via local direct IPC (no Gateway).
|
|
* Events will be pushed to renderer via 'localChat:event' channel.
|
|
*/
|
|
ipcMain.handle('localChat:send', async (_event, agentId: string, content: string, conversationId?: string) => {
|
|
const h = getHub()
|
|
const resolvedConversationId = conversationId ?? agentId
|
|
const agent = h.getAgent(resolvedConversationId)
|
|
if (!agent) {
|
|
return { error: `Conversation not found: ${resolvedConversationId}` }
|
|
}
|
|
if (agent.closed) {
|
|
return { error: `Conversation is closed: ${resolvedConversationId}` }
|
|
}
|
|
|
|
// Must be subscribed first to receive events
|
|
if (!ipcAgentSubscriptions.has(resolvedConversationId)) {
|
|
return { error: 'Not subscribed to agent events. Call subscribe first.' }
|
|
}
|
|
|
|
h.channelManager.clearLastRoute()
|
|
const source = { type: 'local' as const }
|
|
// Broadcast as local source (for consistency, though UI already knows)
|
|
h.broadcastInbound({
|
|
agentId: h.getConversationAgentId(resolvedConversationId) ?? agentId,
|
|
conversationId: resolvedConversationId,
|
|
content,
|
|
source,
|
|
timestamp: Date.now(),
|
|
})
|
|
agent.write(content, { source })
|
|
safeLog(`[IPC] Local chat message sent to conversation: ${resolvedConversationId}`)
|
|
return { ok: true }
|
|
})
|
|
|
|
/**
|
|
* Abort the current agent run for local chat.
|
|
*/
|
|
ipcMain.handle('localChat:abort', async (_event, agentId: string, conversationId?: string) => {
|
|
const h = getHub()
|
|
const resolvedConversationId = conversationId ?? agentId
|
|
const agent = h.getAgent(resolvedConversationId)
|
|
if (!agent) {
|
|
return { error: `Conversation not found: ${resolvedConversationId}` }
|
|
}
|
|
agent.abort()
|
|
safeLog(`[IPC] Abort sent to conversation: ${resolvedConversationId}`)
|
|
return { ok: true }
|
|
})
|
|
|
|
/**
|
|
* Resolve an exec approval request for local chat.
|
|
*/
|
|
ipcMain.handle('localChat:resolveExecApproval', async (_event, approvalId: string, decision: string) => {
|
|
const h = getHub()
|
|
const ok = h.resolveExecApproval(approvalId, decision as 'allow-once' | 'allow-always' | 'deny')
|
|
return { ok }
|
|
})
|
|
|
|
/**
|
|
* Register a one-time token for device verification.
|
|
* Called by the QR code component when a token is generated or refreshed.
|
|
*/
|
|
ipcMain.handle('hub:registerToken', async (_event, token: string, agentId: string, expiresAt: number) => {
|
|
const h = getHub()
|
|
h.registerToken(token, agentId, expiresAt)
|
|
return { ok: true }
|
|
})
|
|
|
|
/**
|
|
* List all verified (whitelisted) devices.
|
|
*/
|
|
ipcMain.handle('hub:listDevices', async () => {
|
|
const h = getHub()
|
|
return h.deviceStore.listDevices()
|
|
})
|
|
|
|
/**
|
|
* Revoke a device from the whitelist.
|
|
*/
|
|
ipcMain.handle('hub:revokeDevice', async (_event, deviceId: string) => {
|
|
const h = getHub()
|
|
const ok = h.deviceStore.revokeDevice(deviceId)
|
|
// Notify renderer that device list changed
|
|
if (ok && mainWindowRef && !mainWindowRef.isDestroyed()) {
|
|
mainWindowRef.webContents.send('hub:devices-changed')
|
|
}
|
|
return { ok }
|
|
})
|
|
|
|
}
|
|
|
|
/**
|
|
* Set up device confirmation flow between Hub (main process) and renderer.
|
|
* Also stores window reference for local chat IPC events.
|
|
* Must be called after both Hub initialization and window creation.
|
|
*/
|
|
export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): void {
|
|
// Store reference for local chat IPC
|
|
mainWindowRef = mainWindow
|
|
const h = getHub()
|
|
const pendingConfirms = new Map<string, (allowed: boolean) => void>()
|
|
|
|
// Listen for renderer responses to device confirm dialogs
|
|
ipcMain.on('hub:device-confirm-response', (_event, deviceId: string, allowed: boolean) => {
|
|
const resolve = pendingConfirms.get(deviceId)
|
|
if (resolve) {
|
|
pendingConfirms.delete(deviceId)
|
|
resolve(allowed)
|
|
}
|
|
})
|
|
|
|
// Register confirm handler on Hub — sends request to renderer, awaits response
|
|
h.setConfirmHandler((deviceId: string, _agentId: string, meta) => {
|
|
return new Promise<boolean>((resolve) => {
|
|
// Auto-reject if user doesn't respond within 60 seconds
|
|
const timeout = setTimeout(() => {
|
|
pendingConfirms.delete(deviceId)
|
|
resolve(false)
|
|
}, 60_000)
|
|
pendingConfirms.set(deviceId, (allowed: boolean) => {
|
|
clearTimeout(timeout)
|
|
resolve(allowed)
|
|
// Notify renderer that device list changed when a device is approved
|
|
if (allowed && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('hub:devices-changed')
|
|
}
|
|
})
|
|
mainWindow.webContents.send('hub:device-confirm-request', deviceId, meta)
|
|
})
|
|
})
|
|
|
|
// Forward connection state changes to renderer
|
|
h.onConnectionStateChange((state) => {
|
|
if (!mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('hub:connection-state-changed', state)
|
|
}
|
|
})
|
|
|
|
// Forward inbound messages from all sources (gateway, channel) to renderer
|
|
h.onInboundMessage((event) => {
|
|
if (!mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('hub:inbound-message', event)
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Cleanup Hub resources.
|
|
*/
|
|
export function cleanupHub(): void {
|
|
// Unsubscribe all IPC listeners
|
|
for (const unsubscribe of ipcAgentSubscriptions.values()) {
|
|
unsubscribe()
|
|
}
|
|
ipcAgentSubscriptions.clear()
|
|
|
|
if (hub) {
|
|
safeLog('[Desktop] Shutting down Hub')
|
|
hub.shutdown()
|
|
hub = null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the current Hub instance (for use by other IPC modules).
|
|
*/
|
|
export function getCurrentHub(): Hub | null {
|
|
return hub
|
|
}
|