Merge pull request #222 from multica-ai/codex/conversation-fasttrack-1-rebased

refactor: adopt conversation-first architecture across core and apps (rebased)
This commit is contained in:
Jiayuan Zhang 2026-02-17 10:01:26 +08:00 committed by GitHub
commit 700e64342c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 3770 additions and 1031 deletions

View file

@ -70,18 +70,20 @@ interface SkillInfo {
triggers: string[]
}
interface DeviceMeta {
userAgent?: string
platform?: string
language?: string
}
interface DeviceEntryInfo {
deviceId: string
agentId: string
addedAt: number
meta?: DeviceMeta
}
interface DeviceMeta {
userAgent?: string
platform?: string
language?: string
clientName?: string
}
interface DeviceEntryInfo {
deviceId: string
agentId: string
conversationIds: string[]
addedAt: number
meta?: DeviceMeta
}
interface SkillAddResult {
ok: boolean
@ -96,10 +98,11 @@ interface ProfileData {
userContent: string | undefined
}
interface LocalChatEvent {
agentId: string
streamId?: string
type?: 'error'
interface LocalChatEvent {
agentId: string
conversationId: string
streamId?: string
type?: 'error'
content?: string
event?: {
type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_update' | 'tool_execution_end' | 'compaction_start' | 'compaction_end'
@ -112,11 +115,12 @@ interface LocalChatEvent {
}
}
interface LocalChatApproval {
approvalId: string
agentId: string
command: string
cwd?: string
interface LocalChatApproval {
approvalId: string
agentId: string
conversationId: string
command: string
cwd?: string
riskLevel: 'safe' | 'needs-review' | 'dangerous'
riskReasons: string[]
expiresAtMs: number
@ -155,12 +159,13 @@ type MessageSource =
| { type: 'gateway'; deviceId: string }
| { type: 'channel'; channelId: string; accountId: string; conversationId: string }
interface InboundMessageEvent {
agentId: string
content: string
source: MessageSource
timestamp: number
}
interface InboundMessageEvent {
agentId: string
conversationId: string
content: string
source: MessageSource
timestamp: number
}
interface ElectronAPI {
app: {
@ -173,16 +178,16 @@ interface ElectronAPI {
hub: {
init: () => Promise<unknown>
getStatus: () => Promise<HubStatus>
getAgentInfo: () => Promise<AgentInfo | null>
info: () => Promise<unknown>
reconnect: (url: string) => Promise<unknown>
listAgents: () => Promise<unknown>
createAgent: (id?: string) => Promise<unknown>
getAgent: (id: string) => Promise<unknown>
closeAgent: (id: string) => Promise<unknown>
sendMessage: (agentId: string, content: string) => Promise<unknown>
registerToken: (token: string, agentId: string, expiresAt: number) => Promise<unknown>
onDeviceConfirmRequest: (callback: (deviceId: string, meta?: DeviceMeta) => void) => void
getAgentInfo: () => Promise<AgentInfo | null>
info: () => Promise<unknown>
reconnect: (url: string) => Promise<unknown>
listConversations: () => Promise<unknown>
createConversation: (id?: string) => Promise<unknown>
getConversation: (id: string) => Promise<unknown>
closeConversation: (id: string) => Promise<unknown>
sendMessage: (agentId: string, content: string, conversationId: string) => Promise<unknown>
registerToken: (token: string, agentId: string, conversationId: string, expiresAt: number) => Promise<unknown>
onDeviceConfirmRequest: (callback: (deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => void) => void
offDeviceConfirmRequest: () => void
deviceConfirmResponse: (deviceId: string, allowed: boolean) => void
listDevices: () => Promise<DeviceEntryInfo[]>
@ -246,13 +251,13 @@ interface ElectronAPI {
last: () => Promise<unknown>
setEnabled: (enabled: boolean) => Promise<{ ok: boolean; enabled?: boolean; error?: string }>
wake: (reason?: string) => Promise<{ ok: boolean; result?: unknown; error?: string }>
}
}
localChat: {
subscribe: (agentId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }>
unsubscribe: (agentId: string) => Promise<{ ok: boolean }>
getHistory: (agentId: string, options?: { offset?: number; limit?: number }) => Promise<{ messages: unknown[]; total: number; offset: number; limit: number; contextWindowTokens?: number }>
send: (agentId: string, content: string) => Promise<{ ok?: boolean; error?: string }>
abort: (agentId: string) => Promise<{ ok?: boolean; error?: string }>
subscribe: (conversationId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }>
unsubscribe: (conversationId: string) => Promise<{ ok: boolean }>
getHistory: (conversationId: string, options?: { offset?: number; limit?: number }) => Promise<{ messages: unknown[]; total: number; offset: number; limit: number; contextWindowTokens?: number }>
send: (conversationId: string, content: string) => Promise<{ ok?: boolean; error?: string }>
abort: (conversationId: string) => Promise<{ ok?: boolean; error?: string }>
resolveExecApproval: (approvalId: string, decision: string) => Promise<{ ok: boolean }>
onEvent: (callback: (event: LocalChatEvent) => void) => void
offEvent: () => void

View file

@ -47,10 +47,10 @@ function getDefaultAgent() {
const hub = getCurrentHub()
if (!hub) return null
const agentIds = hub.listAgents()
if (agentIds.length === 0) return null
const conversationIds = hub.listConversations()
if (conversationIds.length === 0) return null
return hub.getAgent(agentIds[0]) ?? null
return hub.getConversation(conversationIds[0]) ?? null
}
/**

View file

@ -10,7 +10,7 @@ import type { ConnectionState } from '@multica/sdk'
// Singleton Hub instance
let hub: Hub | null = null
let defaultAgentId: string | null = null
let defaultConversationId: string | null = null
let mainWindowRef: BrowserWindow | null = null
// Track which agents have active IPC subscriptions (for local direct chat)
@ -35,7 +35,7 @@ function safeLog(...args: unknown[]): void {
/**
* Initialize Hub on app startup.
* Creates Hub and a default Agent automatically.
* Creates Hub and a default conversation automatically.
*/
export async function initializeHub(): Promise<void> {
if (hub) {
@ -47,16 +47,16 @@ export async function initializeHub(): Promise<void> {
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}`)
// Create default conversation if none exists
const conversations = hub.listConversations()
if (conversations.length === 0) {
safeLog('[Desktop] Creating default conversation...')
const conversation = hub.createConversation()
defaultConversationId = conversation.sessionId
safeLog(`[Desktop] Default conversation created: ${defaultConversationId}`)
} else {
defaultAgentId = agents[0]
safeLog(`[Desktop] Using existing agent: ${defaultAgentId}`)
defaultConversationId = conversations[0]
safeLog(`[Desktop] Using existing conversation: ${defaultConversationId}`)
}
}
@ -75,8 +75,8 @@ function getHub(): Hub {
* Get the default agent.
*/
export function getDefaultAgent(): AsyncAgent | null {
if (!hub || !defaultAgentId) return null
return hub.getAgent(defaultAgentId) ?? null
if (!hub || !defaultConversationId) return null
return hub.getConversation(defaultConversationId) ?? null
}
/**
@ -111,7 +111,7 @@ export function registerHubIpcHandlers(): void {
hubId: h.hubId,
url: h.url,
connectionState: h.connectionState,
defaultAgentId,
defaultConversationId,
}
})
@ -124,7 +124,7 @@ export function registerHubIpcHandlers(): void {
hubId: h.hubId,
url: h.url,
connectionState: h.connectionState,
agentCount: h.listAgents().length,
agentCount: h.listConversations().length,
}
})
@ -138,12 +138,12 @@ export function registerHubIpcHandlers(): void {
return {
hubId: h.hubId,
status: h.connectionState === 'connected' ? 'ready' : h.connectionState,
agentCount: h.listAgents().length,
agentCount: h.listConversations().length,
gatewayConnected: h.connectionState === 'connected',
gatewayUrl: h.url,
defaultAgent: agent
? {
agentId: agent.sessionId,
agentId: defaultConversationId ?? agent.sessionId,
status: agent.closed ? 'closed' : 'idle',
}
: null,
@ -159,7 +159,7 @@ export function registerHubIpcHandlers(): void {
return null
}
return {
agentId: agent.sessionId,
agentId: defaultConversationId ?? agent.sessionId,
status: agent.closed ? 'closed' : 'idle',
}
})
@ -174,53 +174,53 @@ export function registerHubIpcHandlers(): void {
})
/**
* List all agents.
* List all conversations.
*/
ipcMain.handle('hub:listAgents', async (): Promise<AgentInfo[]> => {
ipcMain.handle('hub:listConversations', async (): Promise<AgentInfo[]> => {
const h = getHub()
const agentIds = h.listAgents()
return agentIds.map((id) => {
const agent = h.getAgent(id)
const conversationIds = h.listConversations()
return conversationIds.map((id) => {
const conversation = h.getConversation(id)
return {
id,
closed: agent?.closed ?? true,
closed: conversation?.closed ?? true,
}
})
})
/**
* Create a new agent.
* Create a new conversation.
*/
ipcMain.handle('hub:createAgent', async (_event, id?: string) => {
ipcMain.handle('hub:createConversation', async (_event, id?: string) => {
const h = getHub()
const agent = h.createAgent(id)
const conversation = h.createConversation(id)
return {
id: agent.sessionId,
closed: agent.closed,
id: conversation.sessionId,
closed: conversation.closed,
}
})
/**
* Get a specific agent.
* Get a specific conversation.
*/
ipcMain.handle('hub:getAgent', async (_event, id: string) => {
ipcMain.handle('hub:getConversation', async (_event, id: string) => {
const h = getHub()
const agent = h.getAgent(id)
if (!agent) {
return { error: `Agent not found: ${id}` }
const conversation = h.getConversation(id)
if (!conversation) {
return { error: `Conversation not found: ${id}` }
}
return {
id: agent.sessionId,
closed: agent.closed,
id: conversation.sessionId,
closed: conversation.closed,
}
})
/**
* Close/delete an agent.
* Close/delete a conversation.
*/
ipcMain.handle('hub:closeAgent', async (_event, id: string) => {
ipcMain.handle('hub:closeConversation', async (_event, id: string) => {
const h = getHub()
const result = h.closeAgent(id)
const result = h.closeConversation(id)
return { ok: result }
})
@ -228,14 +228,15 @@ export function registerHubIpcHandlers(): void {
* 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) => {
ipcMain.handle('hub:sendMessage', async (_event, agentId: string, content: string, conversationId: string) => {
const h = getHub()
const agent = h.getAgent(agentId)
const resolvedConversationId = conversationId
const agent = h.getConversation(resolvedConversationId)
if (!agent) {
return { error: `Agent not found: ${agentId}` }
return { error: `Conversation not found: ${resolvedConversationId}` }
}
if (agent.closed) {
return { error: `Agent is closed: ${agentId}` }
return { error: `Conversation is closed: ${resolvedConversationId}` }
}
h.channelManager.clearLastRoute()
agent.write(content)
@ -246,18 +247,19 @@ export function registerHubIpcHandlers(): void {
* 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) => {
ipcMain.handle('localChat:subscribe', async (_event, conversationId: string) => {
const h = getHub()
const agent = h.getAgent(agentId)
if (!agent) {
return { error: `Agent not found: ${agentId}` }
const conversation = h.getConversation(conversationId)
if (!conversation) {
return { error: `Conversation not found: ${conversationId}` }
}
if (agent.closed) {
return { error: `Agent is closed: ${agentId}` }
if (conversation.closed) {
return { error: `Conversation is closed: ${conversationId}` }
}
const logicalAgentId = h.getConversationAgentId(conversationId) ?? conversationId
// Already subscribed?
if (ipcAgentSubscriptions.has(agentId)) {
if (ipcAgentSubscriptions.has(conversationId)) {
return { ok: true, alreadySubscribed: true }
}
@ -265,7 +267,7 @@ export function registerHubIpcHandlers(): void {
let currentStreamId: string | null = null
// Subscribe to agent events using the multi-subscriber mechanism
const unsubscribe = agent.subscribe((event) => {
const unsubscribe = conversation.subscribe((event) => {
if (!mainWindowRef || mainWindowRef.isDestroyed()) {
return
}
@ -276,8 +278,8 @@ export function registerHubIpcHandlers(): void {
if (isPassthroughEvent) {
safeLog(`[IPC] Sending ${event.type} event to renderer`)
mainWindowRef.webContents.send('localChat:event', {
agentId,
streamId: null,
agentId: logicalAgentId,
conversationId,
event,
})
return
@ -304,7 +306,8 @@ export function registerHubIpcHandlers(): void {
safeLog(`[IPC] Sending event to renderer: ${event.type}, streamId: ${currentStreamId}`)
mainWindowRef.webContents.send('localChat:event', {
agentId,
agentId: logicalAgentId,
conversationId,
streamId: currentStreamId,
event,
})
@ -315,16 +318,16 @@ export function registerHubIpcHandlers(): void {
}
})
ipcAgentSubscriptions.set(agentId, unsubscribe)
ipcAgentSubscriptions.set(conversationId, unsubscribe)
// Register local approval handler so exec approval requests route via IPC
h.setLocalApprovalHandler(agentId, (payload) => {
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 agent: ${agentId}`)
safeLog(`[IPC] Local chat subscribed to conversation: ${conversationId}`)
return { ok: true }
})
@ -332,14 +335,14 @@ export function registerHubIpcHandlers(): void {
/**
* Unsubscribe from local agent events.
*/
ipcMain.handle('localChat:unsubscribe', async (_event, agentId: string) => {
const unsubscribe = ipcAgentSubscriptions.get(agentId)
ipcMain.handle('localChat:unsubscribe', async (_event, conversationId: string) => {
const unsubscribe = ipcAgentSubscriptions.get(conversationId)
if (unsubscribe) {
unsubscribe()
}
ipcAgentSubscriptions.delete(agentId)
getHub().removeLocalApprovalHandler(agentId)
safeLog(`[IPC] Local chat unsubscribed from agent: ${agentId}`)
ipcAgentSubscriptions.delete(conversationId)
getHub().removeLocalApprovalHandler(conversationId)
safeLog(`[IPC] Local chat unsubscribed from conversation: ${conversationId}`)
return { ok: true }
})
@ -351,9 +354,13 @@ export function registerHubIpcHandlers(): void {
* 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 }) => {
ipcMain.handle('localChat:getHistory', async (
_event,
conversationId: string,
options?: { offset?: number; limit?: number },
) => {
const h = getHub()
const agent = h.getAgent(agentId)
const agent = h.getConversation(conversationId)
if (!agent) {
return { messages: [], total: 0, offset: 0, limit: 0, contextWindowTokens: undefined }
}
@ -377,46 +384,49 @@ export function registerHubIpcHandlers(): void {
* 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) => {
ipcMain.handle('localChat:send', async (_event, conversationId: string, content: string) => {
const h = getHub()
const agent = h.getAgent(agentId)
const resolvedConversationId = conversationId
const agent = h.getConversation(resolvedConversationId)
if (!agent) {
return { error: `Agent not found: ${agentId}` }
return { error: `Conversation not found: ${resolvedConversationId}` }
}
if (agent.closed) {
return { error: `Agent is closed: ${agentId}` }
return { error: `Conversation is closed: ${resolvedConversationId}` }
}
// Must be subscribed first to receive events
if (!ipcAgentSubscriptions.has(agentId)) {
return { error: 'Not subscribed to agent events. Call subscribe first.' }
if (!ipcAgentSubscriptions.has(resolvedConversationId)) {
return { error: 'Not subscribed to conversation 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,
agentId: h.getConversationAgentId(resolvedConversationId) ?? resolvedConversationId,
conversationId: resolvedConversationId,
content,
source,
timestamp: Date.now(),
})
agent.write(content, { source })
safeLog(`[IPC] Local chat message sent to agent: ${agentId}`)
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) => {
ipcMain.handle('localChat:abort', async (_event, conversationId: string) => {
const h = getHub()
const agent = h.getAgent(agentId)
const resolvedConversationId = conversationId
const agent = h.getConversation(resolvedConversationId)
if (!agent) {
return { error: `Agent not found: ${agentId}` }
return { error: `Conversation not found: ${resolvedConversationId}` }
}
agent.abort()
safeLog(`[IPC] Abort sent to agent: ${agentId}`)
safeLog(`[IPC] Abort sent to conversation: ${resolvedConversationId}`)
return { ok: true }
})
@ -433,11 +443,14 @@ export function registerHubIpcHandlers(): void {
* 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) => {
ipcMain.handle(
'hub:registerToken',
async (_event, token: string, agentId: string, conversationId: string, expiresAt: number) => {
const h = getHub()
h.registerToken(token, agentId, expiresAt)
h.registerToken(token, agentId, conversationId, expiresAt)
return { ok: true }
})
},
)
/**
* List all verified (whitelisted) devices.
@ -483,7 +496,7 @@ export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): voi
})
// Register confirm handler on Hub — sends request to renderer, awaits response
h.setConfirmHandler((deviceId: string, _agentId: string, meta) => {
h.setConfirmHandler((deviceId: string, agentId: string, conversationId: string, meta) => {
return new Promise<boolean>((resolve) => {
// Auto-reject if user doesn't respond within 60 seconds
const timeout = setTimeout(() => {
@ -498,7 +511,7 @@ export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): voi
mainWindow.webContents.send('hub:devices-changed')
}
})
mainWindow.webContents.send('hub:device-confirm-request', deviceId, meta)
mainWindow.webContents.send('hub:device-confirm-request', deviceId, agentId, conversationId, meta)
})
})

View file

@ -13,10 +13,10 @@ function getDefaultAgent() {
const hub = getCurrentHub()
if (!hub) return null
const agentIds = hub.listAgents()
if (agentIds.length === 0) return null
const conversationIds = hub.listConversations()
if (conversationIds.length === 0) return null
return hub.getAgent(agentIds[0]) ?? null
return hub.getConversation(conversationIds[0]) ?? null
}
/**

View file

@ -53,10 +53,10 @@ function getDefaultAgent() {
const hub = getCurrentHub()
if (!hub) return null
const agentIds = hub.listAgents()
if (agentIds.length === 0) return null
const conversationIds = hub.listConversations()
if (conversationIds.length === 0) return null
return hub.getAgent(agentIds[0]) ?? null
return hub.getConversation(conversationIds[0]) ?? null
}
/**

View file

@ -27,10 +27,10 @@ function getDefaultAgent() {
const hub = getCurrentHub()
if (!hub) return null
const agentIds = hub.listAgents()
if (agentIds.length === 0) return null
const conversationIds = hub.listConversations()
if (conversationIds.length === 0) return null
return hub.getAgent(agentIds[0]) ?? null
return hub.getConversation(conversationIds[0]) ?? null
}
/**

View file

@ -65,21 +65,22 @@ export interface CurrentProviderInfo {
}
// Local chat event types (for direct IPC communication without Gateway)
export interface LocalChatEvent {
agentId: string
streamId?: string
type?: 'error'
content?: string
export interface LocalChatEvent {
agentId: string
conversationId: string
streamId?: string
type?: 'error'
content?: string
event?: {
type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_update' | 'tool_execution_end' | 'compaction_start' | 'compaction_end'
id?: string
message?: {
role: string
content?: Array<{ type: string; text?: string }>
}
[key: string]: unknown
}
}
content?: Array<{ type: string; text?: string }>
}
[key: string]: unknown
}
}
// Inbound message event (from any source: local, gateway, channel)
export type MessageSource =
@ -87,19 +88,21 @@ export type MessageSource =
| { type: 'gateway'; deviceId: string }
| { type: 'channel'; channelId: string; accountId: string; conversationId: string }
export interface InboundMessageEvent {
agentId: string
content: string
source: MessageSource
timestamp: number
}
export interface InboundMessageEvent {
agentId: string
conversationId: string
content: string
source: MessageSource
timestamp: number
}
// Local chat approval request (mirrors ExecApprovalRequestPayload from @multica/sdk)
export interface LocalChatApproval {
approvalId: string
agentId: string
command: string
cwd?: string
export interface LocalChatApproval {
approvalId: string
agentId: string
conversationId: string
command: string
cwd?: string
riskLevel: 'safe' | 'needs-review' | 'dangerous'
riskReasons: string[]
expiresAtMs: number
@ -154,20 +157,36 @@ const electronAPI = {
hub: {
init: () => ipcRenderer.invoke('hub:init'),
getStatus: (): Promise<HubStatus> => ipcRenderer.invoke('hub:getStatus'),
getAgentInfo: (): Promise<AgentInfo | null> => ipcRenderer.invoke('hub:getAgentInfo'),
info: () => ipcRenderer.invoke('hub:info'),
reconnect: (url: string) => ipcRenderer.invoke('hub:reconnect', url),
listAgents: () => ipcRenderer.invoke('hub:listAgents'),
createAgent: (id?: string) => ipcRenderer.invoke('hub:createAgent', id),
getAgent: (id: string) => ipcRenderer.invoke('hub:getAgent', id),
closeAgent: (id: string) => ipcRenderer.invoke('hub:closeAgent', id),
sendMessage: (agentId: string, content: string) =>
ipcRenderer.invoke('hub:sendMessage', agentId, content),
registerToken: (token: string, agentId: string, expiresAt: number) =>
ipcRenderer.invoke('hub:registerToken', token, agentId, expiresAt),
onDeviceConfirmRequest: (callback: (deviceId: string, meta?: { userAgent?: string; platform?: string; language?: string }) => void) => {
ipcRenderer.on('hub:device-confirm-request', (_event, deviceId: string, meta?: { userAgent?: string; platform?: string; language?: string }) => callback(deviceId, meta))
},
getAgentInfo: (): Promise<AgentInfo | null> => ipcRenderer.invoke('hub:getAgentInfo'),
info: () => ipcRenderer.invoke('hub:info'),
reconnect: (url: string) => ipcRenderer.invoke('hub:reconnect', url),
listConversations: () => ipcRenderer.invoke('hub:listConversations'),
createConversation: (id?: string) => ipcRenderer.invoke('hub:createConversation', id),
getConversation: (id: string) => ipcRenderer.invoke('hub:getConversation', id),
closeConversation: (id: string) => ipcRenderer.invoke('hub:closeConversation', id),
sendMessage: (agentId: string, content: string, conversationId: string) =>
ipcRenderer.invoke('hub:sendMessage', agentId, content, conversationId),
registerToken: (token: string, agentId: string, conversationId: string, expiresAt: number) =>
ipcRenderer.invoke('hub:registerToken', token, agentId, conversationId, expiresAt),
onDeviceConfirmRequest: (
callback: (
deviceId: string,
agentId: string,
conversationId: string,
meta?: { userAgent?: string; platform?: string; language?: string; clientName?: string },
) => void,
) => {
ipcRenderer.on(
'hub:device-confirm-request',
(
_event,
deviceId: string,
agentId: string,
conversationId: string,
meta?: { userAgent?: string; platform?: string; language?: string; clientName?: string },
) => callback(deviceId, agentId, conversationId, meta),
)
},
offDeviceConfirmRequest: () => {
ipcRenderer.removeAllListeners('hub:device-confirm-request')
},
@ -310,19 +329,21 @@ const electronAPI = {
},
},
// Local chat (direct IPC, no Gateway required)
localChat: {
/** Subscribe to agent events for local direct chat */
subscribe: (agentId: string) => ipcRenderer.invoke('localChat:subscribe', agentId),
/** Unsubscribe from agent events */
unsubscribe: (agentId: string) => ipcRenderer.invoke('localChat:unsubscribe', agentId),
/** Get message history for local chat with pagination (returns raw AgentMessageItem[]) */
getHistory: (agentId: string, options?: { offset?: number; limit?: number }) =>
ipcRenderer.invoke('localChat:getHistory', agentId, options),
/** Send message to agent via direct IPC (no Gateway) */
send: (agentId: string, content: string) => ipcRenderer.invoke('localChat:send', agentId, content),
/** Abort the current agent run */
abort: (agentId: string) => ipcRenderer.invoke('localChat:abort', agentId),
// Local chat (direct IPC, no Gateway required)
localChat: {
/** Subscribe to conversation events for local direct chat */
subscribe: (conversationId: string) => ipcRenderer.invoke('localChat:subscribe', conversationId),
/** Unsubscribe from conversation events */
unsubscribe: (conversationId: string) => ipcRenderer.invoke('localChat:unsubscribe', conversationId),
/** Get message history for local chat with pagination (returns raw AgentMessageItem[]) */
getHistory: (conversationId: string, options?: { offset?: number; limit?: number }) =>
ipcRenderer.invoke('localChat:getHistory', conversationId, options),
/** Send message to conversation via direct IPC (no Gateway) */
send: (conversationId: string, content: string) =>
ipcRenderer.invoke('localChat:send', conversationId, content),
/** Abort the current agent run */
abort: (conversationId: string) =>
ipcRenderer.invoke('localChat:abort', conversationId),
/** Resolve an exec approval request */
resolveExecApproval: (approvalId: string, decision: string) =>
ipcRenderer.invoke('localChat:resolveExecApproval', approvalId, decision),

View file

@ -20,6 +20,8 @@ interface DeviceMeta {
interface PendingConfirm {
deviceId: string
agentId: string
conversationId: string
meta?: DeviceMeta
}
@ -32,9 +34,11 @@ export function DeviceConfirmDialog() {
const [pending, setPending] = useState<PendingConfirm | null>(null)
useEffect(() => {
window.electronAPI?.hub.onDeviceConfirmRequest((deviceId: string, meta?: DeviceMeta) => {
setPending({ deviceId, meta })
})
window.electronAPI?.hub.onDeviceConfirmRequest(
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => {
setPending({ deviceId, agentId, conversationId, meta })
},
)
return () => {
window.electronAPI?.hub.offDeviceConfirmRequest()
}

View file

@ -10,12 +10,13 @@ import { QueuedMessageBar } from './queued-message-bar'
interface LocalChatProps {
initialPrompt?: string
conversationId?: string
}
export function LocalChat({ initialPrompt }: LocalChatProps) {
export function LocalChat({ initialPrompt, conversationId }: LocalChatProps) {
const navigate = useNavigate()
const {
agentId,
conversationId: activeConversationId,
initError,
messages,
streamingIds,
@ -34,7 +35,7 @@ export function LocalChat({ initialPrompt }: LocalChatProps) {
loadMore,
resolveApproval,
clearError,
} = useLocalChat()
} = useLocalChat({ conversationId })
const { providers, current, setProvider: switchProvider, refresh: refreshProviders } = useProviderStore()
@ -71,18 +72,21 @@ export function LocalChat({ initialPrompt }: LocalChatProps) {
// Auto-send initial prompt after a short delay
const lastPromptRef = useRef<string | undefined>(undefined)
useEffect(() => {
if (!agentId || !initialPrompt) return
if (!activeConversationId || !initialPrompt) return
if (initialPrompt === lastPromptRef.current) return
const timer = setTimeout(() => {
lastPromptRef.current = initialPrompt
sendMessage(initialPrompt)
// Remove prompt from URL to prevent re-sending on back navigation
navigate('/chat', { replace: true })
const nextPath = activeConversationId
? `/chat?conversation=${encodeURIComponent(activeConversationId)}`
: '/chat'
navigate(nextPath, { replace: true })
}, 500)
return () => clearTimeout(timer)
}, [agentId, initialPrompt, sendMessage, navigate])
}, [activeConversationId, initialPrompt, sendMessage, navigate])
if (initError) {
return (
@ -92,7 +96,7 @@ export function LocalChat({ initialPrompt }: LocalChatProps) {
)
}
if (!agentId) {
if (!activeConversationId) {
return (
<div className="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-sm">
<Loading />

View file

@ -11,6 +11,7 @@ export interface QRCodeData {
gateway: string
hubId: string
agentId: string
conversationId?: string
token: string
expires: number
}
@ -19,6 +20,7 @@ export interface ConnectionQRCodeProps {
gateway: string
hubId: string
agentId: string
conversationId: string
expirySeconds?: number
size?: number
}
@ -125,10 +127,11 @@ export function ConnectionQRCode({
gateway,
hubId,
agentId,
conversationId,
expirySeconds = 30,
size = 200,
}: ConnectionQRCodeProps) {
const { token, expiresAt, refresh } = useQRToken(agentId, expirySeconds)
const { token, expiresAt, refresh } = useQRToken(agentId, conversationId, expirySeconds)
const remaining = useCountdown(expiresAt, refresh)
// Derive QR data and URL from current token (computed during render)
@ -138,10 +141,11 @@ export function ConnectionQRCode({
gateway,
hubId,
agentId,
conversationId,
token,
expires: expiresAt,
}),
[gateway, hubId, agentId, token, expiresAt]
[gateway, hubId, agentId, conversationId, token, expiresAt]
)
const connectionUrl = useMemo(() => {
@ -149,11 +153,12 @@ export function ConnectionQRCode({
gateway,
hub: hubId,
agent: agentId,
conversation: conversationId,
token,
exp: expiresAt.toString(),
})
return `multica://connect?${params.toString()}`
}, [gateway, hubId, agentId, token, expiresAt])
}, [gateway, hubId, agentId, conversationId, token, expiresAt])
return (
<div className="flex flex-col items-center gap-4">

View file

@ -11,7 +11,7 @@ function generateToken(): string {
* - Auto-refreshes when expired
* - Registers token with Hub
*/
export function useQRToken(agentId: string, expirySeconds: number) {
export function useQRToken(agentId: string, conversationId: string, expirySeconds: number) {
const [token, setToken] = useState(generateToken)
const [expiresAt, setExpiresAt] = useState(() => Date.now() + expirySeconds * 1000)
@ -20,12 +20,12 @@ export function useQRToken(agentId: string, expirySeconds: number) {
const newExpiry = Date.now() + expirySeconds * 1000
setToken(newToken)
setExpiresAt(newExpiry)
window.electronAPI?.hub.registerToken(newToken, agentId, newExpiry)
}, [agentId, expirySeconds])
window.electronAPI?.hub.registerToken(newToken, agentId, conversationId, newExpiry)
}, [agentId, conversationId, expirySeconds])
// Register initial token
useEffect(() => {
window.electronAPI?.hub.registerToken(token, agentId, expiresAt)
window.electronAPI?.hub.registerToken(token, agentId, conversationId, expiresAt)
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return { token, expiresAt, refresh }

View file

@ -8,6 +8,7 @@ export interface TelegramConnectQRProps {
gateway: string
hubId: string
agentId: string
conversationId: string
expirySeconds?: number
size?: number
}
@ -23,10 +24,11 @@ export function TelegramConnectQR({
gateway,
hubId,
agentId,
conversationId,
expirySeconds = 30,
size = 200,
}: TelegramConnectQRProps) {
const { token, expiresAt, refresh } = useQRToken(agentId, expirySeconds)
const { token, expiresAt, refresh } = useQRToken(agentId, conversationId, expirySeconds)
const remaining = useCountdown(expiresAt, refresh)
const [deepLink, setDeepLink] = useState<string | null>(null)
@ -44,7 +46,14 @@ export function TelegramConnectQR({
const res = await fetch(`${gateway}/telegram/connect-code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gateway, hubId, agentId, token, expires: expiresAt }),
body: JSON.stringify({
gateway,
hubId,
agentId,
conversationId,
token,
expires: expiresAt,
}),
})
if (cancelled) return
@ -72,7 +81,7 @@ export function TelegramConnectQR({
fetchCode()
return () => { cancelled = true }
}, [token, expiresAt, gateway, hubId, agentId])
}, [token, expiresAt, gateway, hubId, agentId, conversationId])
if (loading) {
return (

View file

@ -14,6 +14,7 @@ export interface DeviceMeta {
export interface DeviceEntry {
deviceId: string
agentId: string
conversationIds: string[]
addedAt: number
meta?: DeviceMeta
}

View file

@ -15,11 +15,16 @@ export interface QueuedLocalMessage {
createdAt: number
}
interface UseLocalChatOptions {
conversationId?: string
}
function makeQueueId(): string {
return globalThis.crypto?.randomUUID?.() ?? `queued-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
export function useLocalChat() {
export function useLocalChat(options: UseLocalChatOptions = {}) {
const requestedConversationId = options.conversationId
const chat = useChat()
const chatRef = useRef(chat)
chatRef.current = chat
@ -33,6 +38,7 @@ export function useLocalChat() {
const [initError, setInitError] = useState<string | null>(null)
const initRef = useRef(false)
const offsetRef = useRef<number | null>(null)
const activeConversationId = requestedConversationId ?? agentId
// Initialize hub and get default agent ID
useEffect(() => {
@ -41,10 +47,13 @@ export function useLocalChat() {
window.electronAPI.hub.init()
.then((result) => {
const r = result as { defaultAgentId?: string }
console.log('[LocalChat] hub.init → defaultAgentId:', r.defaultAgentId)
if (r.defaultAgentId) {
setAgentId(r.defaultAgentId)
const r = result as { defaultConversationId?: string }
const defaultConversationId = r.defaultConversationId
console.log('[LocalChat] hub.init → defaultConversationId:', defaultConversationId)
if (defaultConversationId) {
setAgentId(defaultConversationId)
} else if (requestedConversationId) {
setAgentId(requestedConversationId)
} else {
setInitError('No default agent available')
setIsLoadingHistory(false)
@ -54,14 +63,19 @@ export function useLocalChat() {
setInitError(err.message)
setIsLoadingHistory(false)
})
}, [])
}, [requestedConversationId])
// Subscribe to events + fetch history once agentId is available
// Subscribe to events + fetch history once conversation is available
useEffect(() => {
if (!agentId) return
if (!activeConversationId) return
setQueuedMessages([])
offsetRef.current = null
setIsLoading(false)
setIsLoadingHistory(true)
chatRef.current.reset()
// Subscribe to agent events
window.electronAPI.localChat.subscribe(agentId).catch(() => {})
window.electronAPI.localChat.subscribe(activeConversationId).catch(() => {})
// Listen for stream events
window.electronAPI.localChat.onEvent((data) => {
@ -104,24 +118,38 @@ export function useLocalChat() {
// Listen for inbound messages from all sources (gateway, channel)
// This allows the local UI to display messages from other sources
window.electronAPI.hub.onInboundMessage((event: InboundMessageEvent) => {
const eventConversationId = event.conversationId
// Only add non-local messages (local messages are added by sendMessage)
if (event.source.type !== 'local' && event.agentId === agentId) {
chatRef.current.addUserMessage(event.content, event.agentId, event.source as MessageSource)
if (event.source.type !== 'local' && eventConversationId === activeConversationId) {
chatRef.current.addUserMessage(
event.content,
event.agentId,
eventConversationId,
event.source as MessageSource,
)
setIsLoading(true)
}
})
// Fetch history with pagination
window.electronAPI.localChat.getHistory(agentId, { limit: DEFAULT_MESSAGES_LIMIT })
window.electronAPI.localChat.getHistory(activeConversationId, {
limit: DEFAULT_MESSAGES_LIMIT,
})
.then((result) => {
console.log('[LocalChat] getHistory result:', result.messages?.length, 'messages, total:', result.total)
if (result.messages?.length) {
chatRef.current.setHistory(result.messages as AgentMessageItem[], agentId, {
chatRef.current.setHistory(result.messages as AgentMessageItem[], activeConversationId, activeConversationId, {
total: result.total,
offset: result.offset,
contextWindowTokens: result.contextWindowTokens,
})
offsetRef.current = result.offset
} else {
chatRef.current.setHistory([], activeConversationId, activeConversationId, {
total: 0,
offset: 0,
contextWindowTokens: result.contextWindowTokens,
})
}
})
.catch(() => {})
@ -131,9 +159,9 @@ export function useLocalChat() {
window.electronAPI.localChat.offEvent()
window.electronAPI.localChat.offApproval()
window.electronAPI.hub.offInboundMessage()
window.electronAPI.localChat.unsubscribe(agentId).catch(() => {})
window.electronAPI.localChat.unsubscribe(activeConversationId).catch(() => {})
}
}, [agentId])
}, [activeConversationId])
useEffect(() => {
isLoadingRef.current = isLoading
@ -141,11 +169,11 @@ export function useLocalChat() {
const dispatchMessageNow = useCallback((text: string) => {
const trimmed = text.trim()
if (!trimmed || !agentId) return
chatRef.current.addUserMessage(trimmed, agentId, { type: 'local' })
if (!trimmed || !activeConversationId) return
chatRef.current.addUserMessage(trimmed, activeConversationId, activeConversationId, { type: 'local' })
chatRef.current.setError(null)
setIsLoading(true)
window.electronAPI.localChat.send(agentId, trimmed)
window.electronAPI.localChat.send(activeConversationId, trimmed)
.then((result) => {
const response = result as { ok?: boolean; error?: string } | undefined
if (response?.error) {
@ -155,11 +183,11 @@ export function useLocalChat() {
.catch(() => {
setIsLoading(false)
})
}, [agentId])
}, [activeConversationId])
const sendMessage = useCallback((text: string) => {
const trimmed = text.trim()
if (!trimmed || !agentId) return
if (!trimmed || !activeConversationId) return
if (isLoadingRef.current) {
setQueuedMessages((prev) => [
@ -174,7 +202,7 @@ export function useLocalChat() {
}
dispatchMessageNow(trimmed)
}, [agentId, dispatchMessageNow])
}, [activeConversationId, dispatchMessageNow])
const removeQueuedMessage = useCallback((id: string) => {
setQueuedMessages((prev) => prev.filter((item) => item.id !== id))
@ -185,31 +213,34 @@ export function useLocalChat() {
}, [])
useEffect(() => {
if (!agentId || isLoading || queuedMessages.length === 0) return
if (!activeConversationId || isLoading || queuedMessages.length === 0) return
const next = queuedMessages[0]
if (!next) return
setQueuedMessages((prev) => prev.slice(1))
dispatchMessageNow(next.text)
}, [agentId, isLoading, queuedMessages, dispatchMessageNow])
}, [activeConversationId, isLoading, queuedMessages, dispatchMessageNow])
const abortGeneration = useCallback(() => {
if (!agentId) return
window.electronAPI.localChat.abort(agentId).catch(() => {})
if (!activeConversationId) return
window.electronAPI.localChat.abort(activeConversationId).catch(() => {})
setIsLoading(false)
}, [agentId])
}, [activeConversationId])
const loadMore = useCallback(async () => {
const currentOffset = offsetRef.current
if (!agentId || currentOffset == null || currentOffset <= 0 || isLoadingMoreRef.current) return
if (!activeConversationId || currentOffset == null || currentOffset <= 0 || isLoadingMoreRef.current) return
isLoadingMoreRef.current = true
setIsLoadingMore(true)
try {
const newOffset = Math.max(0, currentOffset - DEFAULT_MESSAGES_LIMIT)
const limit = currentOffset - newOffset
const result = await window.electronAPI.localChat.getHistory(agentId, { offset: newOffset, limit })
const result = await window.electronAPI.localChat.getHistory(activeConversationId, {
offset: newOffset,
limit,
})
if (result.messages?.length) {
chatRef.current.prependHistory(result.messages as AgentMessageItem[], agentId, {
chatRef.current.prependHistory(result.messages as AgentMessageItem[], activeConversationId, activeConversationId, {
total: result.total,
offset: result.offset,
contextWindowTokens: result.contextWindowTokens,
@ -222,7 +253,7 @@ export function useLocalChat() {
isLoadingMoreRef.current = false
setIsLoadingMore(false)
}
}, [agentId])
}, [activeConversationId])
const resolveApproval = useCallback(
(approvalId: string, decision: ApprovalDecision) => {
@ -238,6 +269,7 @@ export function useLocalChat() {
return {
agentId,
conversationId: activeConversationId,
initError,
messages: chat.messages,
streamingIds: chat.streamingIds,

View file

@ -118,6 +118,7 @@ export default function HomePage() {
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id}
conversationId={primaryAgent?.id}
expirySeconds={30}
size={180}
/>

View file

@ -34,6 +34,7 @@ function ChannelsContent() {
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id ?? 'unknown'}
conversationId={primaryAgent?.id ?? 'unknown'}
expirySeconds={30}
size={200}
/>

View file

@ -12,6 +12,7 @@ import {
MessageSquare,
Users,
Clock,
Plus,
ChevronLeft,
ChevronRight,
ChevronDown,
@ -48,6 +49,7 @@ import { LocalChat } from '../components/local-chat'
import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
import { UpdateNotification } from '../components/update-notification'
import { useAuthStore } from '../stores/auth'
import { useHubStore } from '../stores/hub'
const mainNavItems = [
{ path: '/', label: 'Home', icon: Home, exact: true },
@ -72,6 +74,11 @@ const allNavItems: Array<{ path: string; label: string; icon: typeof Home; exact
...bottomNavItems,
]
function shortConversationId(id: string): string {
if (id.length <= 18) return id
return `${id.slice(0, 8)}...${id.slice(-8)}`
}
function NavigationButtons() {
const navigate = useNavigate()
useLocation()
@ -155,6 +162,13 @@ export default function Layout() {
const isAgentActive = location.pathname.startsWith('/agent')
const isOnChat = location.pathname === '/chat'
const { user, clearAuth } = useAuthStore()
const { agents, refresh: refreshAgents, createConversation } = useHubStore()
const [isCreatingConversation, setIsCreatingConversation] = useState(false)
const [conversationError, setConversationError] = useState<string | null>(null)
const selectedConversationId = isOnChat
? new URLSearchParams(location.search).get('conversation') ?? undefined
: undefined
const activeConversationId = selectedConversationId ?? agents[0]?.id
// Lazy mount: only mount Chat on first visit, then keep it mounted forever
const [chatMounted, setChatMounted] = useState(false)
@ -162,6 +176,19 @@ export default function Layout() {
if (isOnChat && !chatMounted) setChatMounted(true)
}, [isOnChat, chatMounted])
useEffect(() => {
if (!isOnChat) return
void refreshAgents()
}, [isOnChat, refreshAgents])
useEffect(() => {
if (!isOnChat || !selectedConversationId || agents.length === 0) return
const exists = agents.some((item) => item.id === selectedConversationId)
if (!exists) {
navigate('/chat', { replace: true })
}
}, [isOnChat, selectedConversationId, agents, navigate])
// Extract initialPrompt from URL search params when navigating to /chat?prompt=...
const initialPrompt = isOnChat
? new URLSearchParams(location.search).get('prompt') ?? undefined
@ -172,6 +199,29 @@ export default function Layout() {
navigate('/login')
}
const openConversation = (id: string) => {
if (!id) return
navigate(`/chat?conversation=${encodeURIComponent(id)}`)
}
const handleCreateConversation = async () => {
setConversationError(null)
setIsCreatingConversation(true)
try {
const created = await createConversation()
if (!created?.id) {
setConversationError('Failed to create session')
return
}
openConversation(created.id)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setConversationError(message)
} finally {
setIsCreatingConversation(false)
}
}
return (
<div className="flex h-screen flex-col bg-background text-foreground">
<SidebarProvider className="flex-1 overflow-hidden">
@ -304,7 +354,56 @@ export default function Layout() {
</div>
{chatMounted && (
<div className={cn('h-full flex flex-col overflow-hidden', !isOnChat && 'hidden')}>
<LocalChat initialPrompt={initialPrompt} />
<div className="border-b px-4 py-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-muted-foreground">Session</span>
{activeConversationId ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm" className="h-7 max-w-64 justify-start font-mono text-xs" />
}
>
{shortConversationId(activeConversationId)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-80">
{agents.length === 0 ? (
<DropdownMenuItem disabled>
No sessions available
</DropdownMenuItem>
) : (
agents.map((item) => (
<DropdownMenuItem key={item.id} onClick={() => openConversation(item.id)}>
<span className="font-mono text-xs truncate">{item.id}</span>
{item.id === activeConversationId && <span className="ml-auto text-xs">Current</span>}
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<span className="text-xs text-muted-foreground">Initializing...</span>
)}
</div>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 shrink-0"
onClick={handleCreateConversation}
disabled={isCreatingConversation}
>
<Plus className="size-3.5" />
{isCreatingConversation ? 'Creating...' : 'New Session'}
</Button>
</div>
{conversationError && (
<div className="px-4 py-2 text-xs text-destructive border-b">
{conversationError}
</div>
)}
<LocalChat initialPrompt={initialPrompt} conversationId={activeConversationId} />
</div>
)}
</main>

View file

@ -25,6 +25,8 @@ interface DeviceMeta {
interface PendingConfirm {
deviceId: string
agentId: string
conversationId: string
meta?: DeviceMeta
}
@ -42,9 +44,11 @@ export default function ConnectStep({ onNext, onBack }: ConnectStepProps) {
// Listen for device confirm requests during onboarding
useEffect(() => {
window.electronAPI?.hub.onDeviceConfirmRequest((deviceId: string, meta?: DeviceMeta) => {
setPending({ deviceId, meta })
})
window.electronAPI?.hub.onDeviceConfirmRequest(
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => {
setPending({ deviceId, agentId, conversationId, meta })
},
)
return () => {
window.electronAPI?.hub.offDeviceConfirmRequest()
}
@ -120,6 +124,7 @@ export default function ConnectStep({ onNext, onBack }: ConnectStepProps) {
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id ?? 'unknown'}
conversationId={primaryAgent?.id ?? 'unknown'}
expirySeconds={30}
size={180}
/>

View file

@ -27,6 +27,8 @@ interface HubStore {
init: () => Promise<void>
refresh: () => Promise<void>
reconnect: (url: string) => Promise<{ ok: boolean; error?: string }>
createConversation: (id?: string) => Promise<AgentInfo | null>
closeConversation: (id: string) => Promise<boolean>
}
export const useHubStore = create<HubStore>()((set, get) => ({
@ -45,7 +47,7 @@ export const useHubStore = create<HubStore>()((set, get) => ({
try {
await window.electronAPI.hub.init()
const info = await window.electronAPI.hub.info()
const agentList = await window.electronAPI.hub.listAgents()
const agentList = await window.electronAPI.hub.listConversations()
set({
hubInfo: info as HubInfo,
@ -75,7 +77,7 @@ export const useHubStore = create<HubStore>()((set, get) => ({
try {
const info = await window.electronAPI.hub.info()
const agentList = await window.electronAPI.hub.listAgents()
const agentList = await window.electronAPI.hub.listConversations()
set({
hubInfo: info as HubInfo,
@ -101,6 +103,34 @@ export const useHubStore = create<HubStore>()((set, get) => ({
return { ok: false, error: message }
}
},
createConversation: async (id?: string) => {
try {
const result = await window.electronAPI.hub.createConversation(id) as { id?: string; closed?: boolean }
await get().refresh()
if (!result?.id) return null
return {
id: result.id,
closed: result.closed ?? false,
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
return null
}
},
closeConversation: async (id: string) => {
try {
const result = await window.electronAPI.hub.closeConversation(id) as { ok?: boolean }
await get().refresh()
return !!result?.ok
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
return false
}
},
}))
// Selector helpers

View file

@ -1,12 +1,14 @@
-- Telegram users table for Gateway
-- Run this manually before starting the Gateway with Telegram enabled.
DROP TABLE IF EXISTS telegram_thread_routes;
DROP TABLE IF EXISTS telegram_users;
CREATE TABLE telegram_users (
telegram_user_id VARCHAR(64) PRIMARY KEY,
hub_id VARCHAR(64) NOT NULL,
agent_id VARCHAR(64) NOT NULL,
conversation_id VARCHAR(64) NULL,
device_id VARCHAR(64) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@ -15,3 +17,18 @@ CREATE TABLE telegram_users (
telegram_last_name VARCHAR(255),
INDEX idx_device_id (device_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE telegram_thread_routes (
telegram_user_id VARCHAR(64) NOT NULL,
chat_id VARCHAR(64) NOT NULL,
thread_id VARCHAR(64) NOT NULL DEFAULT '0',
conversation_id VARCHAR(64) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (telegram_user_id, chat_id, thread_id),
INDEX idx_conversation_id (conversation_id),
CONSTRAINT fk_telegram_thread_routes_user
FOREIGN KEY (telegram_user_id)
REFERENCES telegram_users(telegram_user_id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -726,7 +726,7 @@
from: deviceId,
to: targetDeviceId,
action: 'message',
payload: { agentId: targetAgentId, content: text },
payload: { agentId: targetAgentId, conversationId: targetAgentId, content: text },
});
appendMsg('self', text);

View file

@ -5,25 +5,25 @@ import { MessageContextQueue } from "./message-context-queue.js";
describe("MessageContextQueue", () => {
it("keeps the first context active while newer messages stay pending", () => {
const queue = new MessageContextQueue();
const deviceId = "device-1";
const contextKey = "device-1:session-1";
queue.enqueue(deviceId, { telegramChatId: 100, telegramMessageId: 1 });
queue.enqueue(deviceId, { telegramChatId: 100, telegramMessageId: 2 });
queue.enqueue(contextKey, { telegramChatId: 100, telegramMessageId: 1 });
queue.enqueue(contextKey, { telegramChatId: 100, telegramMessageId: 2 });
assert.deepEqual(queue.activate(deviceId), {
assert.deepEqual(queue.activate(contextKey), {
telegramChatId: 100,
telegramMessageId: 1,
});
assert.deepEqual(queue.peekForSend(deviceId), {
assert.deepEqual(queue.peekForSend(contextKey), {
telegramChatId: 100,
telegramMessageId: 1,
});
assert.deepEqual(queue.release(deviceId), {
assert.deepEqual(queue.release(contextKey), {
telegramChatId: 100,
telegramMessageId: 1,
});
assert.deepEqual(queue.peekForSend(deviceId), {
assert.deepEqual(queue.peekForSend(contextKey), {
telegramChatId: 100,
telegramMessageId: 2,
});
@ -31,17 +31,17 @@ describe("MessageContextQueue", () => {
it("releases oldest pending context when a run errors before message_start", () => {
const queue = new MessageContextQueue();
const deviceId = "device-2";
const contextKey = "device-2:session-2";
queue.enqueue(deviceId, { telegramChatId: 200, telegramMessageId: 11 });
queue.enqueue(deviceId, { telegramChatId: 200, telegramMessageId: 12 });
queue.enqueue(contextKey, { telegramChatId: 200, telegramMessageId: 11 });
queue.enqueue(contextKey, { telegramChatId: 200, telegramMessageId: 12 });
// No activate(): simulate agent_error before streaming starts
assert.deepEqual(queue.release(deviceId), {
assert.deepEqual(queue.release(contextKey), {
telegramChatId: 200,
telegramMessageId: 11,
});
assert.deepEqual(queue.peekForSend(deviceId), {
assert.deepEqual(queue.peekForSend(contextKey), {
telegramChatId: 200,
telegramMessageId: 12,
});
@ -49,28 +49,28 @@ describe("MessageContextQueue", () => {
it("does not advance queue on repeated activate calls during one run", () => {
const queue = new MessageContextQueue();
const deviceId = "device-3";
const contextKey = "device-3:session-3";
queue.enqueue(deviceId, { telegramChatId: 300, telegramMessageId: 21 });
queue.enqueue(deviceId, { telegramChatId: 300, telegramMessageId: 22 });
queue.enqueue(contextKey, { telegramChatId: 300, telegramMessageId: 21 });
queue.enqueue(contextKey, { telegramChatId: 300, telegramMessageId: 22 });
assert.equal(queue.activate(deviceId)?.telegramMessageId, 21);
assert.equal(queue.activate(deviceId)?.telegramMessageId, 21);
assert.equal(queue.activate(contextKey)?.telegramMessageId, 21);
assert.equal(queue.activate(contextKey)?.telegramMessageId, 21);
assert.equal(queue.release(deviceId)?.telegramMessageId, 21);
assert.equal(queue.peekForSend(deviceId)?.telegramMessageId, 22);
assert.equal(queue.release(contextKey)?.telegramMessageId, 21);
assert.equal(queue.peekForSend(contextKey)?.telegramMessageId, 22);
});
it("isolates contexts by device", () => {
it("isolates contexts by context key", () => {
const queue = new MessageContextQueue();
queue.enqueue("a", { telegramChatId: 1, telegramMessageId: 1 });
queue.enqueue("b", { telegramChatId: 2, telegramMessageId: 2 });
queue.enqueue("a:session-1", { telegramChatId: 1, telegramMessageId: 1 });
queue.enqueue("a:session-2", { telegramChatId: 2, telegramMessageId: 2 });
assert.equal(queue.activate("a")?.telegramMessageId, 1);
assert.equal(queue.peekForSend("b")?.telegramMessageId, 2);
assert.equal(queue.release("a")?.telegramMessageId, 1);
assert.equal(queue.peekForSend("a"), undefined);
assert.equal(queue.peekForSend("b")?.telegramMessageId, 2);
assert.equal(queue.activate("a:session-1")?.telegramMessageId, 1);
assert.equal(queue.peekForSend("a:session-2")?.telegramMessageId, 2);
assert.equal(queue.release("a:session-1")?.telegramMessageId, 1);
assert.equal(queue.peekForSend("a:session-1"), undefined);
assert.equal(queue.peekForSend("a:session-2")?.telegramMessageId, 2);
});
});

View file

@ -14,32 +14,32 @@ export class MessageContextQueue {
private readonly pending = new Map<string, MessageContext[]>();
private readonly active = new Map<string, MessageContext>();
enqueue(deviceId: string, context: MessageContext): void {
const queue = this.pending.get(deviceId);
enqueue(contextKey: string, context: MessageContext): void {
const queue = this.pending.get(contextKey);
if (queue) {
queue.push(context);
return;
}
this.pending.set(deviceId, [context]);
this.pending.set(contextKey, [context]);
}
/**
* Bind the next pending context to the active run for this device.
* If a run is already active, keep it unchanged.
*/
activate(deviceId: string): MessageContext | undefined {
const current = this.active.get(deviceId);
activate(contextKey: string): MessageContext | undefined {
const current = this.active.get(contextKey);
if (current) return current;
const queue = this.pending.get(deviceId);
const queue = this.pending.get(contextKey);
if (!queue || queue.length === 0) return undefined;
const next = queue.shift();
if (queue.length === 0) {
this.pending.delete(deviceId);
this.pending.delete(contextKey);
}
if (next) {
this.active.set(deviceId, next);
this.active.set(contextKey, next);
}
return next;
}
@ -48,11 +48,11 @@ export class MessageContextQueue {
* Get the context to use for outbound sends.
* Prefer active run context; otherwise fall back to oldest pending.
*/
peekForSend(deviceId: string): MessageContext | undefined {
const current = this.active.get(deviceId);
peekForSend(contextKey: string): MessageContext | undefined {
const current = this.active.get(contextKey);
if (current) return current;
const queue = this.pending.get(deviceId);
const queue = this.pending.get(contextKey);
return queue?.[0];
}
@ -60,19 +60,19 @@ export class MessageContextQueue {
* Release one context after a run completes/errors.
* Prefer active context; if none active, release oldest pending.
*/
release(deviceId: string): MessageContext | undefined {
const current = this.active.get(deviceId);
release(contextKey: string): MessageContext | undefined {
const current = this.active.get(contextKey);
if (current) {
this.active.delete(deviceId);
this.active.delete(contextKey);
return current;
}
const queue = this.pending.get(deviceId);
const queue = this.pending.get(contextKey);
if (!queue || queue.length === 0) return undefined;
const next = queue.shift();
if (queue.length === 0) {
this.pending.delete(deviceId);
this.pending.delete(contextKey);
}
return next;
}

View file

@ -18,6 +18,7 @@ interface TelegramUserRow extends RowDataPacket {
telegram_user_id: string;
hub_id: string;
agent_id: string;
conversation_id?: string | null;
device_id: string;
created_at: Date;
updated_at: Date;
@ -26,18 +27,51 @@ interface TelegramUserRow extends RowDataPacket {
telegram_last_name: string | null;
}
interface TelegramThreadRouteRow extends RowDataPacket {
telegram_user_id: string;
chat_id: string;
thread_id: string;
conversation_id: string;
}
interface LocalStoreFileShape {
users?: Record<string, TelegramUser>;
threadRoutes?: Record<string, Record<string, string>>;
}
const LOCAL_STORE_DIR = join(DATA_DIR, "gateway");
const LOCAL_STORE_PATH = join(LOCAL_STORE_DIR, "telegram-users.json");
const DEFAULT_THREAD_ID = "0";
@Injectable()
export class TelegramUserStore {
private readonly logger = new Logger(TelegramUserStore.name);
/** Local file-backed store, keyed by telegramUserId */
private localStore = new Map<string, TelegramUser>();
/** Local per-thread conversation routes, keyed by telegramUserId -> routeKey(chatId:threadId) -> conversationId */
private localThreadRoutes = new Map<string, Map<string, string>>();
private localStoreLoaded = false;
private warnedMissingThreadTable = false;
constructor(@Inject(DatabaseService) private readonly db: DatabaseService) {}
private hasMissingConversationColumnError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const maybe = err as { code?: string; message?: string };
return maybe.code === "ER_BAD_FIELD_ERROR" && (maybe.message ?? "").includes("conversation_id");
}
private hasMissingThreadRoutesTableError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const maybe = err as { code?: string; message?: string };
if (maybe.code === "ER_NO_SUCH_TABLE") return true;
return maybe.code === "ER_BAD_FIELD_ERROR" && (maybe.message ?? "").includes("telegram_thread_routes");
}
private getThreadRouteKey(chatId: string, threadId?: string): string {
return `${chatId}:${threadId?.trim() || DEFAULT_THREAD_ID}`;
}
/** Find user by Telegram user ID */
async findByTelegramUserId(telegramUserId: string): Promise<TelegramUser | null> {
if (!this.db.isAvailable()) {
@ -73,6 +107,76 @@ export class TelegramUserStore {
return this.rowToUser(rows[0]!);
}
async getThreadConversation(
telegramUserId: string,
chatId: string,
threadId?: string,
): Promise<string | null> {
const routeKey = this.getThreadRouteKey(chatId, threadId);
if (!this.db.isAvailable()) {
await this.ensureLocalStoreLoaded();
const routes = this.localThreadRoutes.get(telegramUserId);
return routes?.get(routeKey) ?? null;
}
try {
const rows = await this.db.query<TelegramThreadRouteRow[]>(
`SELECT conversation_id
FROM telegram_thread_routes
WHERE telegram_user_id = ? AND chat_id = ? AND thread_id = ?
LIMIT 1`,
[telegramUserId, chatId, threadId?.trim() || DEFAULT_THREAD_ID],
);
return rows[0]?.conversation_id ?? null;
} catch (err) {
if (!this.hasMissingThreadRoutesTableError(err)) throw err;
if (!this.warnedMissingThreadTable) {
this.warnedMissingThreadTable = true;
this.logger.warn(
"telegram_thread_routes table missing; per-thread conversation routing will fall back to default conversation",
);
}
return null;
}
}
async setThreadConversation(
telegramUserId: string,
chatId: string,
conversationId: string,
threadId?: string,
): Promise<void> {
const routeKey = this.getThreadRouteKey(chatId, threadId);
if (!this.db.isAvailable()) {
await this.ensureLocalStoreLoaded();
const routes = this.localThreadRoutes.get(telegramUserId) ?? new Map<string, string>();
routes.set(routeKey, conversationId);
this.localThreadRoutes.set(telegramUserId, routes);
await this.saveLocalStore();
return;
}
try {
await this.db.execute(
`INSERT INTO telegram_thread_routes (
telegram_user_id, chat_id, thread_id, conversation_id
) VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE conversation_id = VALUES(conversation_id), updated_at = CURRENT_TIMESTAMP`,
[telegramUserId, chatId, threadId?.trim() || DEFAULT_THREAD_ID, conversationId],
);
} catch (err) {
if (!this.hasMissingThreadRoutesTableError(err)) throw err;
if (!this.warnedMissingThreadTable) {
this.warnedMissingThreadTable = true;
this.logger.warn(
"telegram_thread_routes table missing; per-thread conversation routing will not persist in DB",
);
}
}
}
/** Create or update a Telegram user */
async upsert(data: TelegramUserCreate): Promise<TelegramUser> {
if (!this.db.isAvailable()) {
@ -84,25 +188,51 @@ export class TelegramUserStore {
if (existing) {
// Update existing user — also update device_id if provided
await this.db.execute(
`UPDATE telegram_users SET
hub_id = ?,
agent_id = ?,
device_id = ?,
telegram_username = ?,
telegram_first_name = ?,
telegram_last_name = ?
WHERE telegram_user_id = ?`,
[
data.hubId,
data.agentId,
data.deviceId ?? existing.deviceId,
data.telegramUsername ?? null,
data.telegramFirstName ?? null,
data.telegramLastName ?? null,
data.telegramUserId,
]
);
const nextConversationId = data.conversationId ?? existing.conversationId ?? existing.agentId;
try {
await this.db.execute(
`UPDATE telegram_users SET
hub_id = ?,
agent_id = ?,
conversation_id = ?,
device_id = ?,
telegram_username = ?,
telegram_first_name = ?,
telegram_last_name = ?
WHERE telegram_user_id = ?`,
[
data.hubId,
data.agentId,
nextConversationId,
data.deviceId ?? existing.deviceId,
data.telegramUsername ?? null,
data.telegramFirstName ?? null,
data.telegramLastName ?? null,
data.telegramUserId,
]
);
} catch (err) {
if (!this.hasMissingConversationColumnError(err)) throw err;
await this.db.execute(
`UPDATE telegram_users SET
hub_id = ?,
agent_id = ?,
device_id = ?,
telegram_username = ?,
telegram_first_name = ?,
telegram_last_name = ?
WHERE telegram_user_id = ?`,
[
data.hubId,
data.agentId,
data.deviceId ?? existing.deviceId,
data.telegramUsername ?? null,
data.telegramFirstName ?? null,
data.telegramLastName ?? null,
data.telegramUserId,
]
);
}
const updated = await this.findByTelegramUserId(data.telegramUserId);
return updated!;
@ -110,22 +240,43 @@ export class TelegramUserStore {
// Create new user with provided or generated device ID
const deviceId = data.deviceId ?? `tg-${generateEncryptedId()}`;
const conversationId = data.conversationId ?? data.agentId;
await this.db.execute(
`INSERT INTO telegram_users (
telegram_user_id, hub_id, agent_id, device_id,
telegram_username, telegram_first_name, telegram_last_name
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
data.telegramUserId,
data.hubId,
data.agentId,
deviceId,
data.telegramUsername ?? null,
data.telegramFirstName ?? null,
data.telegramLastName ?? null,
]
);
try {
await this.db.execute(
`INSERT INTO telegram_users (
telegram_user_id, hub_id, agent_id, conversation_id, device_id,
telegram_username, telegram_first_name, telegram_last_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
data.telegramUserId,
data.hubId,
data.agentId,
conversationId,
deviceId,
data.telegramUsername ?? null,
data.telegramFirstName ?? null,
data.telegramLastName ?? null,
]
);
} catch (err) {
if (!this.hasMissingConversationColumnError(err)) throw err;
await this.db.execute(
`INSERT INTO telegram_users (
telegram_user_id, hub_id, agent_id, device_id,
telegram_username, telegram_first_name, telegram_last_name
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
data.telegramUserId,
data.hubId,
data.agentId,
deviceId,
data.telegramUsername ?? null,
data.telegramFirstName ?? null,
data.telegramLastName ?? null,
]
);
}
const created = await this.findByTelegramUserId(data.telegramUserId);
return created!;
@ -144,13 +295,28 @@ export class TelegramUserStore {
try {
const data = await readFile(LOCAL_STORE_PATH, "utf-8");
const records = JSON.parse(data) as Record<string, TelegramUser>;
const parsed = JSON.parse(data) as LocalStoreFileShape | Record<string, TelegramUser>;
const records = (parsed as LocalStoreFileShape).users
?? (parsed as Record<string, TelegramUser>);
const threadRoutes = (parsed as LocalStoreFileShape).threadRoutes ?? {};
for (const [key, user] of Object.entries(records)) {
// Restore Date objects from JSON strings
user.createdAt = new Date(user.createdAt);
user.updatedAt = new Date(user.updatedAt);
this.localStore.set(key, user);
}
for (const [userId, routes] of Object.entries(threadRoutes)) {
const map = new Map<string, string>();
for (const [routeKey, conversationId] of Object.entries(routes)) {
if (!routeKey || !conversationId) continue;
map.set(routeKey, conversationId);
}
if (map.size > 0) {
this.localThreadRoutes.set(userId, map);
}
}
this.logger.log(`Loaded ${this.localStore.size} Telegram user(s) from ${LOCAL_STORE_PATH}`);
} catch {
// File doesn't exist or is invalid — start fresh
@ -163,8 +329,16 @@ export class TelegramUserStore {
for (const [key, user] of this.localStore) {
obj[key] = user;
}
const threadRoutes: Record<string, Record<string, string>> = {};
for (const [userId, routes] of this.localThreadRoutes) {
threadRoutes[userId] = Object.fromEntries(routes.entries());
}
await mkdir(LOCAL_STORE_DIR, { recursive: true });
await writeFile(LOCAL_STORE_PATH, JSON.stringify(obj, null, 2), "utf-8");
await writeFile(
LOCAL_STORE_PATH,
JSON.stringify({ users: obj, threadRoutes }, null, 2),
"utf-8",
);
}
/** Upsert to local file store */
@ -178,6 +352,7 @@ export class TelegramUserStore {
telegramUserId: data.telegramUserId,
hubId: data.hubId,
agentId: data.agentId,
conversationId: data.conversationId ?? existing?.conversationId ?? data.agentId,
deviceId: data.deviceId ?? existing?.deviceId ?? `tg-${generateEncryptedId()}`,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
@ -198,6 +373,7 @@ export class TelegramUserStore {
telegramUserId: row.telegram_user_id,
hubId: row.hub_id,
agentId: row.agent_id,
conversationId: row.conversation_id ?? undefined,
deviceId: row.device_id,
createdAt: row.created_at,
updatedAt: row.updated_at,

View file

@ -29,7 +29,14 @@ export class TelegramController {
@Post("connect-code")
async createConnectCode(
@Body() body: { gateway: string; hubId: string; agentId: string; token: string; expires: number },
@Body() body: {
gateway: string;
hubId: string;
agentId: string;
conversationId: string;
token: string;
expires: number;
},
): Promise<{ code: string; botUsername: string }> {
if (!this.telegramService.isConfigured()) {
throw new HttpException("Telegram bot not configured", HttpStatus.SERVICE_UNAVAILABLE);
@ -45,6 +52,7 @@ export class TelegramController {
gateway: body.gateway,
hubId: body.hubId,
agentId: body.agentId,
conversationId: body.conversationId,
token: body.token,
expires: body.expires,
};

View file

@ -92,6 +92,14 @@ interface GenerateChannelWelcomeResult {
text: string;
}
interface ListConversationsResult {
conversations: Array<{ id: string; closed: boolean }>;
}
interface CreateConversationResult {
id: string;
}
// ── Constants ──
const VERIFY_TIMEOUT_MS = 30_000;
@ -195,6 +203,56 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
@Inject(TelegramUserStore) private readonly userStore: TelegramUserStore,
) {}
private makeConversationContextKey(deviceId: string, conversationId?: string): string {
return `${deviceId}:${conversationId ?? "default"}`;
}
private getThreadRoute(ctx: Context): { chatId: string; threadId: string } | null {
const chatId = ctx.chat?.id;
if (chatId === undefined || chatId === null) return null;
const maybeMsg = ctx.message as { message_thread_id?: number } | undefined;
const threadId = maybeMsg?.message_thread_id != null ? String(maybeMsg.message_thread_id) : "0";
return { chatId: String(chatId), threadId };
}
private async resolveConversationForContext(user: TelegramUser, ctx: Context): Promise<string> {
const fallbackConversationId = user.conversationId ?? user.agentId;
const threadRoute = this.getThreadRoute(ctx);
if (!threadRoute) return fallbackConversationId;
const mappedConversationId = await this.userStore.getThreadConversation(
user.telegramUserId,
threadRoute.chatId,
threadRoute.threadId,
);
if (mappedConversationId) {
return mappedConversationId;
}
await this.userStore.setThreadConversation(
user.telegramUserId,
threadRoute.chatId,
fallbackConversationId,
threadRoute.threadId,
);
return fallbackConversationId;
}
private async bindConversationToContext(
user: TelegramUser,
ctx: Context,
conversationId: string,
): Promise<void> {
const threadRoute = this.getThreadRoute(ctx);
if (!threadRoute) return;
await this.userStore.setThreadConversation(
user.telegramUserId,
threadRoute.chatId,
conversationId,
threadRoute.threadId,
);
}
// ── Lifecycle ──
async onModuleInit(): Promise<void> {
@ -355,6 +413,21 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
await ctx.reply(text, { parse_mode: "HTML", reply_markup: keyboard });
});
this.bot.command("new", async (ctx) => {
if (!this.isPrivateChat(ctx)) return;
await this.handleNewConversationCommand(ctx);
});
this.bot.command("session", async (ctx) => {
if (!this.isPrivateChat(ctx)) return;
await this.handleSessionCommand(ctx, ctx.match?.trim() ?? "");
});
this.bot.command("sessions", async (ctx) => {
if (!this.isPrivateChat(ctx)) return;
await this.handleSessionsCommand(ctx);
});
// Inline button callback queries
this.bot.callbackQuery(CB_HOW_TO_CONNECT, async (ctx) => {
await ctx.answerCallbackQuery();
@ -529,7 +602,8 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
const text =
`<b>Welcome back!</b>\n\n` +
`${statusEmoji} Status: <b>${statusText}</b>\n` +
`Agent: <code>${user.agentId}</code>\n\n` +
`Agent: <code>${user.agentId}</code>\n` +
`Conversation: <code>${user.conversationId ?? user.agentId}</code>\n\n` +
(online
? `Your agent is ready. Just send a message to start chatting.`
: `Your Hub is offline. Make sure the Multica Desktop app is running.`);
@ -563,7 +637,8 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
`<b>Connection Status</b>\n\n` +
`${statusEmoji} <b>${statusLabel}</b>\n\n` +
`Hub: <code>${user.hubId}</code>\n` +
`Agent: <code>${user.agentId}</code>\n\n` +
`Agent: <code>${user.agentId}</code>\n` +
`Conversation: <code>${user.conversationId ?? user.agentId}</code>\n\n` +
(online
? `Your Hub is online and ready to receive messages.`
: `Your Hub is offline. Make sure the Multica Desktop app is running.`);
@ -585,6 +660,9 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
`<b>Commands</b>\n` +
` /start \u2014 Connect your account or see welcome\n` +
` /status \u2014 Check connection status\n` +
` /new \u2014 Start a new isolated conversation\n` +
` /session [id] \u2014 Show or switch current conversation\n` +
` /sessions \u2014 List available conversations\n` +
` /help \u2014 Show this message\n\n` +
`<b>How to connect</b>\n` +
` <b>1.</b> Open Multica Desktop app\n` +
@ -610,6 +688,9 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
await this.bot.api.setMyCommands([
{ command: "start", description: "Connect or show welcome" },
{ command: "status", description: "Check connection status" },
{ command: "new", description: "Create a new conversation" },
{ command: "session", description: "Show/switch current conversation" },
{ command: "sessions", description: "List conversations" },
{ command: "help", description: "Show help and instructions" },
]);
@ -664,7 +745,6 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
if (user) {
// ACK: 👀 reaction on the original message
await this.addReaction(msg.chat.id, msg.message_id, "👀");
this.storeMessageContext(user.deviceId, msg.chat.id, msg.message_id);
// Prepend reply context if user is replying to a specific message
const replyContext = extractReplyContext(ctx);
const content = replyContext ? `${replyContext}\n${text}` : text;
@ -677,6 +757,184 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
await ctx.reply(welcome.text, { parse_mode: "HTML", reply_markup: welcome.keyboard });
}
private async createConversationViaRpc(deviceId: string, hubId: string, agentId?: string): Promise<{ id: string }> {
const created = await this.sendRpc<{ agentId?: string }, CreateConversationResult>(
deviceId,
hubId,
"createConversation",
agentId ? { agentId } : {},
VERIFY_TIMEOUT_MS,
"Create conversation request timed out",
);
return { id: created.id };
}
private async listConversationsViaRpc(deviceId: string, hubId: string): Promise<Array<{ id: string; closed: boolean }>> {
const result = await this.sendRpc<Record<string, never>, ListConversationsResult>(
deviceId,
hubId,
"listConversations",
{},
VERIFY_TIMEOUT_MS,
"List conversations request timed out",
);
return result.conversations;
}
private async handleNewConversationCommand(ctx: Context): Promise<void> {
const telegramUserId = String(ctx.from?.id);
const user = await this.userStore.findByTelegramUserId(telegramUserId);
if (!user) {
await ctx.reply("You are not connected yet. Use /start and connect from Desktop first.");
return;
}
if (!this.eventsGateway.isDeviceRegistered(user.hubId)) {
await ctx.reply(
"Your Hub is currently offline.\n\n" +
"Make sure the Multica Desktop app is running and connected to the Gateway.",
);
return;
}
if (!this.eventsGateway.isDeviceRegistered(user.deviceId)) {
this.registerVirtualDeviceForUser(user.deviceId, user.telegramUserId);
}
try {
const created = await this.createConversationViaRpc(user.deviceId, user.hubId, user.agentId);
await this.userStore.upsert({
telegramUserId: user.telegramUserId,
hubId: user.hubId,
agentId: user.agentId,
conversationId: created.id,
deviceId: user.deviceId,
telegramUsername: user.telegramUsername,
telegramFirstName: user.telegramFirstName,
telegramLastName: user.telegramLastName,
});
await this.bindConversationToContext(user, ctx, created.id);
await ctx.reply(
`<b>\u2705 New conversation created</b>\n\n` +
`Conversation: <code>${created.id}</code>\n\n` +
`All next messages in this Telegram thread will use this conversation.`,
{ parse_mode: "HTML" },
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await ctx.reply(`Failed to create conversation: ${message}`);
}
}
private async handleSessionsCommand(ctx: Context): Promise<void> {
const telegramUserId = String(ctx.from?.id);
const user = await this.userStore.findByTelegramUserId(telegramUserId);
if (!user) {
await ctx.reply("You are not connected yet. Use /start and connect from Desktop first.");
return;
}
if (!this.eventsGateway.isDeviceRegistered(user.hubId)) {
await ctx.reply("Your Hub is offline. Open Desktop and reconnect, then try again.");
return;
}
if (!this.eventsGateway.isDeviceRegistered(user.deviceId)) {
this.registerVirtualDeviceForUser(user.deviceId, user.telegramUserId);
}
try {
const conversations = await this.listConversationsViaRpc(user.deviceId, user.hubId);
const conversationIds = conversations.filter((item) => !item.closed).map((item) => item.id);
if (conversationIds.length === 0) {
await ctx.reply("No conversations found.");
return;
}
const current = await this.resolveConversationForContext(user, ctx);
const lines = conversationIds.slice(0, 20).map((id) => {
const marker = id === current ? "\u2022 current" : "";
return `<code>${id}</code>${marker ? ` ${marker}` : ""}`;
});
const extra = conversationIds.length > 20 ? `\n...and ${conversationIds.length - 20} more` : "";
await ctx.reply(
`<b>Available conversations</b>\n\n` +
`${lines.join("\n")}${extra}\n\n` +
`Use <code>/session &lt;id&gt;</code> to switch.`,
{ parse_mode: "HTML" },
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await ctx.reply(`Failed to load conversations: ${message}`);
}
}
private async handleSessionCommand(ctx: Context, input: string): Promise<void> {
const telegramUserId = String(ctx.from?.id);
const user = await this.userStore.findByTelegramUserId(telegramUserId);
if (!user) {
await ctx.reply("You are not connected yet. Use /start and connect from Desktop first.");
return;
}
const target = input.trim();
const current = await this.resolveConversationForContext(user, ctx);
if (!target) {
await ctx.reply(
`<b>Current conversation</b>\n\n` +
`<code>${current}</code>\n\n` +
`Use <code>/session &lt;id&gt;</code> to switch.`,
{ parse_mode: "HTML" },
);
return;
}
if (!this.eventsGateway.isDeviceRegistered(user.hubId)) {
await ctx.reply("Your Hub is offline. Open Desktop and reconnect, then try again.");
return;
}
if (!this.eventsGateway.isDeviceRegistered(user.deviceId)) {
this.registerVirtualDeviceForUser(user.deviceId, user.telegramUserId);
}
try {
const conversations = await this.listConversationsViaRpc(user.deviceId, user.hubId);
const exists = conversations.some((item) => item.id === target && !item.closed);
if (!exists) {
await ctx.reply(
`Conversation not found: <code>${target}</code>\n\nUse /sessions to list available conversations.`,
{ parse_mode: "HTML" },
);
return;
}
await this.userStore.upsert({
telegramUserId: user.telegramUserId,
hubId: user.hubId,
agentId: user.agentId,
conversationId: target,
deviceId: user.deviceId,
telegramUsername: user.telegramUsername,
telegramFirstName: user.telegramFirstName,
telegramLastName: user.telegramLastName,
});
await this.bindConversationToContext(user, ctx, target);
await ctx.reply(
`<b>\u2705 Conversation switched</b>\n\n` +
`Current conversation: <code>${target}</code>`,
{ parse_mode: "HTML" },
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await ctx.reply(`Failed to switch conversation: ${message}`);
}
}
// ── Inbound: media messages ──
private async handleMediaMessage(ctx: Context, media: MediaAttachment): Promise<void> {
@ -704,7 +962,6 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
// ACK: 👀 reaction
await this.addReaction(msg.chat.id, msg.message_id, "👀");
this.storeMessageContext(user.deviceId, msg.chat.id, msg.message_id);
// Process media → text description (async, may take a few seconds)
const processedText = await this.processMedia({ ...media, caption: caption ?? undefined });
@ -809,7 +1066,7 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
* Send text to a Telegram user/group by deviceId.
* Applies Markdown HTML formatting, text chunking, and reply-to.
*/
async sendToTelegram(deviceId: string, text: string): Promise<void> {
async sendToTelegram(deviceId: string, text: string, conversationId?: string): Promise<void> {
if (!this.bot) return;
const user = await this.userStore.findByDeviceId(deviceId);
@ -819,7 +1076,8 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
}
// Use chatId from message context (supports groups); fall back to user ID (private chat)
const context = this.messageContexts.peekForSend(deviceId);
const contextKey = this.makeConversationContextKey(deviceId, conversationId);
const context = this.messageContexts.peekForSend(contextKey);
const chatId = context?.telegramChatId ?? Number(user.telegramUserId);
const chunks = chunkText(text);
@ -872,13 +1130,15 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
type: string,
caption?: string,
filename?: string,
conversationId?: string,
): Promise<void> {
if (!this.bot) return;
const user = await this.userStore.findByDeviceId(deviceId);
if (!user) return;
const context = this.messageContexts.peekForSend(deviceId);
const contextKey = this.makeConversationContextKey(deviceId, conversationId);
const context = this.messageContexts.peekForSend(contextKey);
const chatId = context?.telegramChatId ?? Number(user.telegramUserId);
const inputFile = new InputFile(data, filename);
@ -964,10 +1224,10 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
// ── Typing indicators ──
private startTyping(deviceId: string): void {
if (this.typingTimers.has(deviceId)) return;
private startTyping(contextKey: string): void {
if (this.typingTimers.has(contextKey)) return;
const context = this.messageContexts.peekForSend(deviceId);
const context = this.messageContexts.peekForSend(contextKey);
if (!context) return;
const chatId = context.telegramChatId;
@ -976,41 +1236,41 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
};
send();
const interval = setInterval(send, 5000);
this.typingTimers.set(deviceId, interval);
this.typingTimers.set(contextKey, interval);
// Safety timeout: auto-stop if no message_end/agent_error arrives
setTimeout(() => {
if (this.typingTimers.get(deviceId) === interval) {
this.stopTyping(deviceId);
if (this.typingTimers.get(contextKey) === interval) {
this.stopTyping(contextKey);
}
}, TYPING_TIMEOUT_MS);
}
private stopTyping(deviceId: string): void {
const timer = this.typingTimers.get(deviceId);
private stopTyping(contextKey: string): void {
const timer = this.typingTimers.get(contextKey);
if (timer) {
clearInterval(timer);
this.typingTimers.delete(deviceId);
this.typingTimers.delete(contextKey);
}
}
// ── Message context tracking ──
private storeMessageContext(deviceId: string, chatId: number, messageId: number): void {
this.messageContexts.enqueue(deviceId, {
private storeMessageContext(contextKey: string, chatId: number, messageId: number): void {
this.messageContexts.enqueue(contextKey, {
telegramChatId: chatId,
telegramMessageId: messageId,
});
}
/** Bind the oldest pending context to the currently running agent response. */
private activateMessageContext(deviceId: string): MessageContext | undefined {
return this.messageContexts.activate(deviceId);
private activateMessageContext(contextKey: string): MessageContext | undefined {
return this.messageContexts.activate(contextKey);
}
/** Remove context and 👀 reaction for a device after response is sent */
private async clearMessageContext(deviceId: string): Promise<void> {
const context = this.messageContexts.release(deviceId);
private async clearMessageContext(contextKey: string): Promise<void> {
const context = this.messageContexts.release(contextKey);
if (context) {
await this.removeReaction(context.telegramChatId, context.telegramMessageId);
}
@ -1073,26 +1333,38 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
? `Telegram @${msg.from.username}`
: `Telegram ${msg?.from?.first_name ?? telegramUserId}`,
});
const conversationId = connectionInfo.conversationId ?? result.conversationId;
// 5. Save to DB
await this.userStore.upsert({
telegramUserId,
hubId: connectionInfo.hubId,
agentId: connectionInfo.agentId,
conversationId,
deviceId,
telegramUsername: msg?.from?.username,
telegramFirstName: msg?.from?.first_name,
telegramLastName: msg?.from?.last_name,
});
const threadRoute = this.getThreadRoute(ctx);
if (threadRoute) {
await this.userStore.setThreadConversation(
telegramUserId,
threadRoute.chatId,
conversationId,
threadRoute.threadId,
);
}
const successKeyboard = new InlineKeyboard()
.text("Check status", CB_CHECK_STATUS)
.text("Help", CB_SHOW_HELP);
await ctx.reply(
`<b>\u2705 Connected successfully!</b>\n\n` +
`<b>\u2705 Connected successfully!</b>\n\n` +
`Hub: <code>${result.hubId}</code>\n` +
`Agent: <code>${result.agentId}</code>\n\n` +
`Agent: <code>${result.agentId}</code>\n` +
`Conversation: <code>${conversationId}</code>\n\n` +
`You can now send messages to interact with your agent.`,
{ parse_mode: "HTML", reply_markup: successKeyboard },
);
@ -1298,13 +1570,19 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
}
// Send message to Hub
const conversationId = await this.resolveConversationForContext(user, ctx);
const msg = ctx.message;
if (msg) {
const contextKey = this.makeConversationContextKey(user.deviceId, conversationId);
this.storeMessageContext(contextKey, msg.chat.id, msg.message_id);
}
const message: RoutedMessage = {
id: uuidv7(),
uid: null,
from: user.deviceId,
to: user.hubId,
action: "message",
payload: { agentId: user.agentId, content: text },
payload: { agentId: user.agentId, conversationId, content: text },
};
const sent = this.eventsGateway.routeFromVirtualDevice(message);
@ -1314,7 +1592,7 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
}
this.logger.debug(
`Routed message to Hub: deviceId=${user.deviceId}, hubId=${user.hubId}, agentId=${user.agentId}`,
`Routed message to Hub: deviceId=${user.deviceId}, hubId=${user.hubId}, agentId=${user.agentId}, conversationId=${conversationId}`,
);
}
@ -1355,11 +1633,13 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
const streamPayload = msg.payload as StreamPayload;
const event = streamPayload?.event;
if (!event || !("type" in event)) return;
const conversationId = streamPayload?.conversationId;
const contextKey = this.makeConversationContextKey(deviceId, conversationId);
// Start typing when LLM begins generating
if (event.type === "message_start") {
this.activateMessageContext(deviceId);
this.startTyping(deviceId);
this.activateMessageContext(contextKey);
this.startTyping(contextKey);
return;
}
@ -1377,9 +1657,9 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
.map((c) => c.text!)
.join("") ?? "";
if (narration) {
void this.sendToTelegram(deviceId, narration).then(() => {
void this.sendToTelegram(deviceId, narration, conversationId).then(() => {
// Re-send typing indicator — Telegram clears it when a message is sent
const ctx = this.messageContexts.peekForSend(deviceId);
const ctx = this.messageContexts.peekForSend(contextKey);
if (ctx) {
void this.bot?.api.sendChatAction(ctx.telegramChatId, "typing").catch(() => {});
}
@ -1388,15 +1668,15 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
return;
}
this.stopTyping(deviceId);
this.stopTyping(contextKey);
if (agentMsg?.content) {
const textContent = agentMsg.content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text!)
.join("");
if (textContent) {
void this.sendToTelegram(deviceId, textContent).then(() => {
void this.clearMessageContext(deviceId);
void this.sendToTelegram(deviceId, textContent, conversationId).then(() => {
void this.clearMessageContext(contextKey);
});
}
}
@ -1405,8 +1685,8 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
// Stop typing on error
if (event.type === "agent_error") {
this.stopTyping(deviceId);
void this.clearMessageContext(deviceId);
this.stopTyping(contextKey);
void this.clearMessageContext(contextKey);
return;
}
@ -1420,25 +1700,36 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
type?: string;
caption?: string;
filename?: string;
conversationId?: string;
};
if (payload?.data) {
const conversationId = payload.conversationId;
const contextKey = this.makeConversationContextKey(deviceId, conversationId);
void this.sendFileToTelegram(
deviceId,
Buffer.from(payload.data, "base64"),
payload.type ?? "document",
payload.caption,
payload.filename,
conversationId,
);
this.activateMessageContext(contextKey);
}
return;
}
// Regular message (e.g., "message" action from Hub)
if (msg.action === "message") {
const payload = msg.payload as { content?: string; agentId?: string };
const payload = msg.payload as {
content?: string;
agentId?: string;
conversationId?: string;
};
if (payload?.content) {
void this.sendToTelegram(deviceId, payload.content).then(() => {
void this.clearMessageContext(deviceId);
const conversationId = payload.conversationId;
const contextKey = this.makeConversationContextKey(deviceId, conversationId);
void this.sendToTelegram(deviceId, payload.content, conversationId).then(() => {
void this.clearMessageContext(contextKey);
});
}
return;
@ -1446,11 +1737,17 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
// Error messages
if (msg.action === "error") {
this.stopTyping(deviceId);
void this.clearMessageContext(deviceId);
const payload = msg.payload as { message?: string; code?: string };
const payload = msg.payload as {
message?: string;
code?: string;
conversationId?: string;
};
const conversationId = payload.conversationId;
const contextKey = this.makeConversationContextKey(deviceId, conversationId);
this.stopTyping(contextKey);
void this.clearMessageContext(contextKey);
if (payload?.message) {
void this.sendToTelegram(deviceId, `Error: ${payload.message}`);
void this.sendToTelegram(deviceId, `Error: ${payload.message}`, conversationId);
}
}
},

View file

@ -7,6 +7,8 @@ export interface TelegramUser {
telegramUserId: string;
hubId: string;
agentId: string;
/** Default session fallback when no per-thread route is stored. */
conversationId?: string | undefined;
deviceId: string;
createdAt: Date;
updatedAt: Date;
@ -20,6 +22,7 @@ export interface TelegramUserCreate {
telegramUserId: string;
hubId: string;
agentId: string;
conversationId?: string | undefined;
deviceId?: string;
telegramUsername?: string | undefined;
telegramFirstName?: string | undefined;

View file

@ -20,7 +20,7 @@ export class AppController {
hubId: this.hub.hubId,
url: this.hub.url,
connectionState: this.hub.connectionState,
agentCount: this.hub.listAgents().length,
agentCount: this.hub.listConversations().length,
};
}
@ -33,23 +33,23 @@ export class AppController {
};
}
@Get("agents")
listAgents() {
return this.hub.listAgents().map((id) => {
const agent = this.hub.getAgent(id);
return { id, closed: agent?.closed ?? true };
@Get("conversations")
listConversations() {
return this.hub.listConversations().map((id) => {
const conversation = this.hub.getConversation(id);
return { id, closed: conversation?.closed ?? true };
});
}
@Post("agents")
createAgent(@Body() body?: { id?: string }) {
const agent = this.hub.createAgent(body?.id);
return { id: agent.sessionId };
@Post("conversations")
createConversation(@Body() body?: { id?: string; agentId?: string }) {
const conversation = this.hub.createConversation(body?.id, { agentId: body?.agentId });
return { id: conversation.sessionId };
}
@Delete("agents/:id")
deleteAgent(@Param("id") id: string) {
const ok = this.hub.closeAgent(id);
@Delete("conversations/:id")
deleteConversation(@Param("id") id: string) {
const ok = this.hub.closeConversation(id);
return { ok };
}
}

View file

@ -232,7 +232,7 @@
color: var(--green);
}
/* ── Agents section ── */
/* ── Conversations section ── */
.section-header {
display: flex;
align-items: center;
@ -347,7 +347,7 @@
<div class="stat-value" id="stat-state"></div>
</div>
<div class="stat-card">
<div class="stat-label">Agents</div>
<div class="stat-label">Conversations</div>
<div class="stat-value" id="stat-agents"></div>
</div>
</div>
@ -359,17 +359,17 @@
<button class="btn btn-accent" onclick="updateGateway()">Connect</button>
</div>
<!-- Agents -->
<!-- Conversations -->
<div class="section-header">
<span class="section-title">Agents</span>
<button class="btn btn-accent" onclick="createAgent()">
<span class="section-title">Conversations</span>
<button class="btn btn-accent" onclick="createConversation()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create Agent
Create Conversation
</button>
</div>
<div class="agent-table" id="agent-table">
<div class="agent-table-head">
<span>Agent ID</span>
<span>Conversation ID</span>
<span>Actions</span>
</div>
<div id="agent-list"></div>
@ -419,9 +419,9 @@
}
async function refresh() {
const [hubRes, agentsRes] = await Promise.all([
const [hubRes, conversationsRes] = await Promise.all([
fetch(`${API}/hub`).then(r => r.json()),
fetch(`${API}/agents`).then(r => r.json()),
fetch(`${API}/conversations`).then(r => r.json()),
]);
// Stats
@ -446,19 +446,19 @@
input.value = hubRes.url || '';
}
// Agent list
// Conversation list
const list = document.getElementById('agent-list');
if (agentsRes.length === 0) {
list.innerHTML = '<div class="empty-state">No agents yet. Create one to get started.</div>';
if (conversationsRes.length === 0) {
list.innerHTML = '<div class="empty-state">No conversations yet. Create one to get started.</div>';
} else {
list.innerHTML = agentsRes.map(a =>
list.innerHTML = conversationsRes.map(a =>
`<div class="agent-row">` +
`<div class="agent-id">` +
`<span title="${a.id}">${a.id}</span>` +
`<button class="copy-btn" onclick="copyText('${a.id}', this)">${copyIcon()}</button>` +
`</div>` +
`<div class="agent-actions">` +
`<button class="btn-danger-ghost btn" onclick="deleteAgent('${a.id}')" title="Delete">${trashIcon()}</button>` +
`<button class="btn-danger-ghost btn" onclick="deleteConversation('${a.id}')" title="Delete">${trashIcon()}</button>` +
`</div>` +
`</div>`
).join('');
@ -482,13 +482,13 @@
refresh();
}
async function createAgent() {
await fetch(`${API}/agents`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
async function createConversation() {
await fetch(`${API}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
refresh();
}
async function deleteAgent(id) {
await fetch(`${API}/agents/${id}`, { method: 'DELETE' });
async function deleteConversation(id) {
await fetch(`${API}/conversations/${id}`, { method: 'DELETE' });
refresh();
}

View file

@ -32,11 +32,12 @@ const ChatPage = () => {
/>
)}
{pageState === "connected" && client && identity && (
{pageState === "connected" && client && identity && identity.conversationId && (
<ConnectedChat
client={client}
hubId={identity.hubId}
agentId={identity.agentId}
conversationId={identity.conversationId}
onDisconnect={disconnect}
/>
)}
@ -49,14 +50,16 @@ function ConnectedChat({
client,
hubId,
agentId,
conversationId,
onDisconnect,
}: {
client: NonNullable<ReturnType<typeof useGatewayConnection>["client"]>;
hubId: string;
agentId: string;
conversationId: string;
onDisconnect: () => void;
}) {
const chat = useGatewayChat({ client, hubId, agentId });
const chat = useGatewayChat({ client, hubId, agentId, conversationId });
return <ChatView {...chat} onDisconnect={onDisconnect} />;
}

View file

@ -52,6 +52,31 @@ pnpm dev:local:archive
- `MULTICA_WORKSPACE_DIR`: override workspace root
- `MULTICA_RUN_LOG=1`: enable structured run-log output
## Agent / Conversation Semantics
- `agentId`: logical owner identity (capabilities/profile scope).
- `conversationId`: isolated runtime thread under an agent.
- `sessionId`: internal runner/storage identifier for a conversation. External protocols use `conversationId`.
Protocol rules:
- Hub RPC is conversation-first: `createConversation/listConversations/deleteConversation`.
- All message, stream, and verify payloads use `conversationId` (no `sessionId` alias fields).
- New integrations should always pass `conversationId` explicitly.
Telegram behavior:
- One Telegram DM binds to one active `conversationId`.
- `/new` creates and switches to a new conversation.
- `/session <id>` switches the active conversation.
- `/sessions` lists available conversations.
Channel route behavior:
- Runtime route key is `channelId:accountId:externalConversationId`.
- Each route key is bound to one Hub `conversationId`.
- Incoming/outgoing channel traffic is isolated per bound conversation (no global first-agent fallback).
## Local Full-Stack Notes (`pnpm dev:local`)
`pnpm dev:local` is the recommended way to run the full local stack for integration work.

View file

@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { inferRunLogToolIsError } from "./runner.js";
describe("inferRunLogToolIsError", () => {
it("returns true when event explicitly marks error", () => {
expect(inferRunLogToolIsError(true, undefined, null)).toBe(true);
});
it("returns true when details.error is present", () => {
expect(
inferRunLogToolIsError(
false,
"{\"error\":true}",
{ error: "Financial Datasets API error", message: "Invalid ticker" },
),
).toBe(true);
});
it("returns true when details.error_type uses boolean-like marker", () => {
expect(inferRunLogToolIsError(false, undefined, { error_type: true })).toBe(true);
expect(inferRunLogToolIsError(false, undefined, { error_type: "true" })).toBe(true);
});
it("returns true when text payload starts with error prefix", () => {
expect(inferRunLogToolIsError(false, "error: ENOENT", null)).toBe(true);
});
it("returns false for successful tool responses", () => {
expect(
inferRunLogToolIsError(
false,
"{\"domain\":\"finance\",\"action\":\"get_price_snapshot\"}",
{ domain: "finance", action: "get_price_snapshot" },
),
).toBe(false);
});
});

View file

@ -71,7 +71,7 @@
import { join } from "path";
import { mkdirSync } from "fs";
import { appendFile } from "fs/promises";
import { resolveBaseDir, type SessionStorageOptions } from "./session/storage.js";
import { ensureSessionDir, resolveSessionDir, type SessionStorageOptions } from "./session/storage.js";
export interface RunLog {
log(event: string, data?: Record<string, unknown>): void;
@ -85,16 +85,10 @@ class FileRunLog implements RunLog {
private flushScheduled = false;
constructor(sessionId: string, options?: SessionStorageOptions) {
const sessionDir = join(resolveBaseDir(options), sessionId);
try {
mkdirSync(sessionDir, { recursive: true });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
mkdirSync(sessionDir, { recursive: true });
} else {
throw err;
}
}
ensureSessionDir(sessionId, options);
const sessionDir = resolveSessionDir(sessionId, options);
// keep an extra guard for run-log-only writes when session dir is cleaned externally
mkdirSync(sessionDir, { recursive: true });
this.filePath = join(sessionDir, "run-log.jsonl");
}

View file

@ -238,6 +238,38 @@ export function evaluateCustomSkillAuthoringConsent(
return { allowAuthoring: false, declined: false };
}
/**
* Infer whether a tool call should be classified as error in run-log.
*
* Some tool adapters encode failures in payload fields (`error`, `error_type`)
* without setting `event.isError=true`. This helper keeps run-log semantics
* consistent for E2E health checks.
*/
export function inferRunLogToolIsError(
eventIsError: unknown,
resultText: string | undefined,
details: Record<string, unknown> | null,
): boolean {
if (eventIsError === true) return true;
if (!details) {
return typeof resultText === "string" && /^error[:\s]/i.test(resultText.trim());
}
const errorType = details.error_type;
if (typeof errorType === "boolean") return errorType;
if (typeof errorType === "string") {
const normalized = errorType.trim().toLowerCase();
if (normalized === "true" || normalized === "error") return true;
}
const errorValue = details.error;
if (typeof errorValue === "string") return errorValue.trim().length > 0;
if (errorValue === true) return true;
if (errorValue && typeof errorValue === "object") return true;
return typeof resultText === "string" && /^error[:\s]/i.test(resultText.trim());
}
// ── Run-log result extraction helpers ──────────────────────────────────────
// Lightweight extractors for tool_end metadata. These mirror the patterns in
// cli/output.ts but are kept separate to avoid CLI-specific dependencies.
@ -400,12 +432,17 @@ export class Agent {
// Load session metadata early so stored provider/model can inform defaults
this.sessionId = options.sessionId ?? uuidv7();
this.guardedExecApproval = this.createGuardedExecApprovalCallback(options.onExecApprovalNeeded);
const storageAgentId = options.ownerAgentId;
this.runLog = createRunLog(
options.enableRunLog ?? !!process.env.MULTICA_RUN_LOG,
this.sessionId,
storageAgentId ? { agentId: storageAgentId } : undefined,
);
const storedMeta = (() => {
const tempSession = new SessionManager({ sessionId: this.sessionId });
const tempSession = new SessionManager({
sessionId: this.sessionId,
...(storageAgentId ? { agentId: storageAgentId } : {}),
});
return tempSession.getMeta();
})();
@ -554,6 +591,7 @@ export class Agent {
// 创建 SessionManager带 context window 配置)
this.session = new SessionManager({
sessionId: this.sessionId,
...(storageAgentId ? { agentId: storageAgentId } : {}),
compactionMode,
// Token 模式参数
contextWindowTokens: this.contextWindowGuard.tokens,
@ -1286,8 +1324,7 @@ export class Agent {
const resultText = extractRunLogResultText(result);
const resultChars = resultText?.length ?? 0;
const details = extractRunLogResultDetails(result);
const isError = Boolean((event as any).isError ?? false);
const isError = inferRunLogToolIsError((event as any).isError, resultText, details);
this.currentRunToolExecutions.push({
toolName,
isError,

View file

@ -1,7 +1,13 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { getModel, type Model, type UserMessage } from "@mariozechner/pi-ai";
import type { SessionEntry, SessionMeta } from "./types.js";
import { appendEntry, readEntries, resolveSessionPath, writeEntries } from "./storage.js";
import {
appendEntry,
readEntries,
resolveSessionPath,
writeEntries,
type SessionStorageOptions,
} from "./storage.js";
import { compactMessages, compactMessagesAsync, type CompactionResult } from "./compaction.js";
import { estimateTokenUsage, estimateMessagesTokens, shouldCompact as shouldCompactTokens } from "../context-window/index.js";
import { credentialManager } from "../credentials.js";
@ -36,6 +42,8 @@ function getSummaryApiKey(): string | undefined {
export type SessionManagerOptions = {
sessionId: string;
baseDir?: string | undefined;
/** Logical owner agent ID for hierarchical session storage. */
agentId?: string | undefined;
// Compaction mode configuration
/** Compaction mode: "tokens" uses token awareness, "summary" uses LLM summary (default) */
@ -81,6 +89,7 @@ export type SessionManagerOptions = {
export class SessionManager {
private readonly sessionId: string;
private readonly baseDir: string | undefined;
private readonly agentId: string | undefined;
private readonly compactionMode: "tokens" | "summary";
// Token mode
private readonly contextWindowTokens: number;
@ -108,6 +117,7 @@ export class SessionManager {
constructor(options: SessionManagerOptions) {
this.sessionId = options.sessionId;
this.baseDir = options.baseDir;
this.agentId = options.agentId;
// Compaction mode (default: summary with LLM-based summarization)
this.compactionMode = options.compactionMode ?? "summary";
@ -174,11 +184,11 @@ export class SessionManager {
}
loadEntries(): SessionEntry[] {
return readEntries(this.sessionId, { baseDir: this.baseDir });
return readEntries(this.sessionId, this.getStorageOptions());
}
async repairIfNeeded(warn?: (message: string) => void): Promise<RepairReport> {
const filePath = resolveSessionPath(this.sessionId, { baseDir: this.baseDir });
const filePath = resolveSessionPath(this.sessionId, this.getStorageOptions());
return repairSessionFileIfNeeded({ sessionFile: filePath, ...(warn !== undefined ? { warn } : {}) });
}
@ -240,7 +250,7 @@ export class SessionManager {
appendEntry(
this.sessionId,
{ type: "meta", meta, timestamp: Date.now() },
{ baseDir: this.baseDir },
this.getStorageOptions(),
),
);
}
@ -258,7 +268,7 @@ export class SessionManager {
contextWindowTokens: this.contextWindowTokens,
settings: this.toolResultTruncation,
saveArtifact: (toolCallId, content) =>
saveToolResultArtifact(this.sessionId, toolCallId, content, { baseDir: this.baseDir }),
saveToolResultArtifact(this.sessionId, toolCallId, content, this.getStorageOptions()),
});
if (result.truncated) {
persistMessage = result.message;
@ -286,7 +296,7 @@ export class SessionManager {
: {}),
...(options?.source !== undefined ? { source: options.source } : {}),
},
{ baseDir: this.baseDir },
this.getStorageOptions(),
),
);
}
@ -463,7 +473,7 @@ export class SessionManager {
});
await this.enqueue(() =>
writeEntries(this.sessionId, entries, { baseDir: this.baseDir }),
writeEntries(this.sessionId, entries, this.getStorageOptions()),
);
return result;
}
@ -483,4 +493,11 @@ export class SessionManager {
});
return this.queue;
}
private getStorageOptions(): SessionStorageOptions {
return {
...(this.baseDir !== undefined ? { baseDir: this.baseDir } : {}),
...(this.agentId !== undefined ? { agentId: this.agentId } : {}),
};
}
}

View file

@ -58,6 +58,11 @@ describe("session/storage", () => {
const result = resolveSessionDir("session-123-abc", { baseDir: testBaseDir });
expect(result).toBe(join(testBaseDir, "session-123-abc"));
});
it("should return hierarchical path when agentId is provided", () => {
const result = resolveSessionDir("conv-1", { baseDir: testBaseDir, agentId: "agent-1" });
expect(result).toBe(join(testBaseDir, "agent-1", "conv-1"));
});
});
describe("resolveSessionPath", () => {
@ -65,6 +70,11 @@ describe("session/storage", () => {
const result = resolveSessionPath("test-session", { baseDir: testBaseDir });
expect(result).toBe(join(testBaseDir, "test-session", "session.jsonl"));
});
it("should return hierarchical path when agentId is provided", () => {
const result = resolveSessionPath("conv-1", { baseDir: testBaseDir, agentId: "agent-1" });
expect(result).toBe(join(testBaseDir, "agent-1", "conv-1", "session.jsonl"));
});
});
describe("ensureSessionDir", () => {
@ -84,6 +94,20 @@ describe("session/storage", () => {
expect(() => ensureSessionDir(sessionId, { baseDir: testBaseDir })).not.toThrow();
expect(existsSync(dir)).toBe(true);
});
it("should migrate legacy flat directory into hierarchical path", () => {
const sessionId = "legacy-migrate";
const legacyDir = join(testBaseDir, sessionId);
mkdirSync(legacyDir, { recursive: true });
writeFileSync(join(legacyDir, "session.jsonl"), '{"type":"meta","meta":{},"timestamp":1}\n');
ensureSessionDir(sessionId, { baseDir: testBaseDir, agentId: "agent-1" });
const nextDir = join(testBaseDir, "agent-1", sessionId);
expect(existsSync(nextDir)).toBe(true);
expect(existsSync(join(nextDir, "session.jsonl"))).toBe(true);
expect(existsSync(legacyDir)).toBe(false);
});
});
describe("readEntries", () => {
@ -102,6 +126,21 @@ describe("session/storage", () => {
expect(entries).toEqual([]);
});
it("should read legacy flat session when hierarchical path is requested", () => {
const sessionId = "legacy-read";
const legacyDir = join(testBaseDir, sessionId);
mkdirSync(legacyDir, { recursive: true });
const entry: SessionEntry = {
type: "message",
message: { role: "user", content: "legacy" } as any,
timestamp: 1000,
};
writeFileSync(join(legacyDir, "session.jsonl"), `${JSON.stringify(entry)}\n`);
const entries = readEntries(sessionId, { baseDir: testBaseDir, agentId: "agent-1" });
expect(entries).toEqual([entry]);
});
it("should parse valid JSONL entries", () => {
const sessionId = "valid-session";
const dir = join(testBaseDir, sessionId);

View file

@ -1,5 +1,5 @@
import { join } from "path";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
import { appendFile, writeFile } from "fs/promises";
import { createHash } from "node:crypto";
import type { SessionEntry } from "./types.js";
@ -8,6 +8,8 @@ import { acquireSessionWriteLock } from "./session-write-lock.js";
export type SessionStorageOptions = {
baseDir?: string | undefined;
/** Owner agent ID. When provided, sessions are stored under agent/conversation hierarchy. */
agentId?: string | undefined;
};
/** Minimum base64 data length to externalize (32KB decoded ≈ 43KB base64) */
@ -17,10 +19,35 @@ export function resolveBaseDir(options?: SessionStorageOptions) {
return options?.baseDir ?? join(DATA_DIR, "sessions");
}
export function resolveSessionDir(sessionId: string, options?: SessionStorageOptions) {
function normalizeId(value: string | undefined): string | undefined {
const normalized = (value ?? "").trim();
return normalized.length > 0 ? normalized : undefined;
}
function resolveLegacySessionDir(sessionId: string, options?: SessionStorageOptions): string {
return join(resolveBaseDir(options), sessionId);
}
function resolvePreferredSessionDir(sessionId: string, options?: SessionStorageOptions): string {
const normalizedAgentId = normalizeId(options?.agentId);
if (!normalizedAgentId) {
return resolveLegacySessionDir(sessionId, options);
}
return join(resolveBaseDir(options), normalizedAgentId, sessionId);
}
function resolveExistingSessionDir(sessionId: string, options?: SessionStorageOptions): string {
const preferred = resolvePreferredSessionDir(sessionId, options);
if (existsSync(preferred)) return preferred;
const legacy = resolveLegacySessionDir(sessionId, options);
if (legacy !== preferred && existsSync(legacy)) return legacy;
return preferred;
}
export function resolveSessionDir(sessionId: string, options?: SessionStorageOptions) {
return resolvePreferredSessionDir(sessionId, options);
}
export function resolveSessionPath(sessionId: string, options?: SessionStorageOptions) {
return join(resolveSessionDir(sessionId, options), "session.jsonl");
}
@ -30,6 +57,19 @@ export function resolveMediaDir(sessionId: string, options?: SessionStorageOptio
}
export function ensureSessionDir(sessionId: string, options?: SessionStorageOptions) {
const preferredDir = resolvePreferredSessionDir(sessionId, options);
const legacyDir = resolveLegacySessionDir(sessionId, options);
if (preferredDir !== legacyDir && existsSync(legacyDir) && !existsSync(preferredDir)) {
try {
mkdirSync(join(preferredDir, ".."), { recursive: true });
renameSync(legacyDir, preferredDir);
return;
} catch {
// Fall through to normal mkdir flow below.
}
}
const dir = resolveSessionDir(sessionId, options);
// mkdirSync with recursive is idempotent (no-op if dir exists),
// so skip the existsSync check to avoid a TOCTOU race.
@ -127,7 +167,7 @@ function internalizeBlock(
// Format A ref: { type: "image", $ref: "media/<hash>.bin" }
if (typeof block.$ref === "string") {
const filePath = join(resolveSessionDir(sessionId, options), block.$ref);
const filePath = join(resolveExistingSessionDir(sessionId, options), block.$ref);
try {
const buffer = readFileSync(filePath);
const data = buffer.toString("base64");
@ -140,7 +180,7 @@ function internalizeBlock(
// Format B ref: { type: "image", source: { type: "$ref", path: "media/<hash>.bin" } }
if (block.source && typeof block.source === "object" && block.source.type === "$ref") {
const filePath = join(resolveSessionDir(sessionId, options), block.source.path);
const filePath = join(resolveExistingSessionDir(sessionId, options), block.source.path);
try {
const buffer = readFileSync(filePath);
const data = buffer.toString("base64");
@ -240,7 +280,7 @@ function internalizeImages(
// ─── Public API ─────────────────────────────────────────────────────────────
export function readEntries(sessionId: string, options?: SessionStorageOptions): SessionEntry[] {
const filePath = resolveSessionPath(sessionId, options);
const filePath = join(resolveExistingSessionDir(sessionId, options), "session.jsonl");
if (!existsSync(filePath)) return [];
const content = readFileSync(filePath, "utf8");
const lines = content.split("\n").filter(Boolean);

View file

@ -24,6 +24,8 @@ export interface ExecApprovalRequest {
approvalId: string;
/** Agent that initiated the command */
agentId: string;
/** Conversation ID that initiated the command. */
conversationId: string;
/** Shell command to execute */
command: string;
/** Working directory */

View file

@ -41,6 +41,8 @@ export type AgentOptions = {
/** Command execution directory */
cwd?: string | undefined;
sessionId?: string | undefined;
/** Logical owner agent ID for hierarchical session storage (agent/conversation). */
ownerAgentId?: string | undefined;
logger?: AgentLogger | undefined;
// === Context Window Guard Configuration ===

View file

@ -1,31 +1,69 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { Hub } from "../hub/hub.js";
import type { AsyncAgent } from "../agent/async-agent.js";
import type { ChannelPlugin } from "./types.js";
import type { ChannelPlugin, ChannelMessage } from "./types.js";
import { ChannelManager } from "./manager.js";
type AgentEventCallback = (event: unknown) => void;
function createHarness() {
type AgentHarness = {
agent: AsyncAgent;
write: ReturnType<typeof vi.fn>;
emit: (event: unknown) => void;
};
function createAgentHarness(sessionId: string): AgentHarness {
let subscriber: AgentEventCallback | null = null;
const write = vi.fn();
const agent = {
sessionId: "agent-1",
sessionId,
closed: false,
subscribe: (callback: AgentEventCallback) => {
subscriber = callback;
return () => {
subscriber = null;
};
},
write,
} as unknown as AsyncAgent;
return {
agent,
write,
emit: (event: unknown) => {
subscriber?.(event);
},
};
}
function createHarness() {
const conversations = new Map<string, AgentHarness>();
let conversationCounter = 0;
const createConversation = vi.fn(() => {
conversationCounter += 1;
const id = `conv-${conversationCounter}`;
const harness = createAgentHarness(id);
conversations.set(id, harness);
return harness.agent;
});
const hub = {
listAgents: () => ["agent-1"],
getAgent: () => agent,
listConversations: vi.fn(() => ["existing-conv"]),
createConversation,
getConversation: vi.fn((conversationId: string) => conversations.get(conversationId)?.agent),
getConversationAgentId: vi.fn(() => "agent-1"),
broadcastInbound: vi.fn(),
} as unknown as Hub;
const replyText = vi.fn(async () => {});
const sendText = vi.fn(async () => {});
const addReaction = vi.fn(async () => {});
const plugin: ChannelPlugin = {
id: "telegram",
meta: {
@ -43,39 +81,83 @@ function createHarness() {
outbound: {
replyText,
sendText,
addReaction,
},
};
const manager = new ChannelManager(hub);
(manager as unknown as { lastRoute: unknown }).lastRoute = {
const routeIncomingToManager = (target: ChannelManager, message: ChannelMessage) => {
(target as unknown as {
routeIncoming: (plugin: ChannelPlugin, accountId: string, message: ChannelMessage) => void;
}).routeIncoming(plugin, "default", message);
};
const getConversationIdByExternal = (
target: ChannelManager,
externalConversationId: string,
): string | undefined => {
const bindings = (target as unknown as {
routeBindings: Map<string, { hubConversationId: string }>;
}).routeBindings;
for (const [routeKey, binding] of bindings.entries()) {
if (routeKey.endsWith(`:${externalConversationId}`)) {
return binding.hubConversationId;
}
}
return undefined;
};
return {
hub,
replyText,
sendText,
addReaction,
plugin,
deliveryCtx: {
channel: "telegram",
accountId: "default",
conversationId: "chat-1",
replyToMessageId: "in-1",
},
createManager: (routeBindingsPath: string) => new ChannelManager(hub, { routeBindingsPath }),
routeIncomingToManager,
getConversationIdByExternal,
conversations,
};
(manager as unknown as { ensureSubscribed: () => void }).ensureSubscribed();
const emit = (event: unknown) => subscriber?.(event);
return { manager, replyText, sendText, emit };
}
describe("channel manager heartbeat filtering", () => {
describe("channel manager route isolation", () => {
let testDir: string;
beforeEach(() => {
vi.useFakeTimers();
testDir = mkdtempSync(join(tmpdir(), "channel-manager-"));
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
rmSync(testDir, { recursive: true, force: true });
});
it("suppresses pure HEARTBEAT_OK in channel outbound", async () => {
const { manager, replyText, sendText, emit } = createHarness();
const routeBindingsPath = join(testDir, "route-bindings.json");
const { createManager, routeIncomingToManager, getConversationIdByExternal, conversations, replyText, sendText } = createHarness();
const manager = createManager(routeBindingsPath);
emit({
routeIncomingToManager(manager, {
messageId: "in-1",
conversationId: "chat-1",
senderId: "user-1",
text: "hi",
chatType: "direct",
});
const hubConversationId = getConversationIdByExternal(manager, "chat-1");
expect(hubConversationId).toBeDefined();
const harness = conversations.get(hubConversationId!);
expect(harness).toBeDefined();
harness!.emit({
type: "message_start",
message: { role: "assistant", content: [] },
});
emit({
harness!.emit({
type: "message_end",
message: { role: "assistant", content: [{ type: "text", text: "HEARTBEAT_OK" }] },
});
@ -89,13 +171,29 @@ describe("channel manager heartbeat filtering", () => {
});
it("keeps forwarding normal assistant replies", async () => {
const { manager, replyText, sendText, emit } = createHarness();
const routeBindingsPath = join(testDir, "route-bindings.json");
const { createManager, routeIncomingToManager, getConversationIdByExternal, conversations, replyText, sendText } = createHarness();
const manager = createManager(routeBindingsPath);
emit({
routeIncomingToManager(manager, {
messageId: "in-1",
conversationId: "chat-1",
senderId: "user-1",
text: "hi",
chatType: "direct",
});
const hubConversationId = getConversationIdByExternal(manager, "chat-1");
expect(hubConversationId).toBeDefined();
const harness = conversations.get(hubConversationId!);
expect(harness).toBeDefined();
harness!.emit({
type: "message_start",
message: { role: "assistant", content: [] },
});
emit({
harness!.emit({
type: "message_end",
message: { role: "assistant", content: [{ type: "text", text: "Reminder: check inbox." }] },
});
@ -116,4 +214,113 @@ describe("channel manager heartbeat filtering", () => {
manager.stopAll();
});
it("binds different external conversations to isolated hub conversations", async () => {
const {
createManager,
hub,
routeIncomingToManager,
getConversationIdByExternal,
conversations,
} = createHarness();
const manager = createManager(join(testDir, "route-bindings.json"));
routeIncomingToManager(manager, {
messageId: "in-a1",
conversationId: "chat-a",
senderId: "user-a",
text: "alpha",
chatType: "group",
});
routeIncomingToManager(manager, {
messageId: "in-b1",
conversationId: "chat-b",
senderId: "user-b",
text: "beta",
chatType: "group",
});
await vi.advanceTimersByTimeAsync(600);
const convA = getConversationIdByExternal(manager, "chat-a");
const convB = getConversationIdByExternal(manager, "chat-b");
expect(convA).toBeDefined();
expect(convB).toBeDefined();
expect(convA).not.toBe(convB);
const harnessA = conversations.get(convA!);
const harnessB = conversations.get(convB!);
expect(harnessA?.write).toHaveBeenCalledTimes(1);
expect(harnessA?.write.mock.calls[0]?.[0]).toContain("alpha");
expect(harnessB?.write).toHaveBeenCalledTimes(1);
expect(harnessB?.write.mock.calls[0]?.[0]).toContain("beta");
// Same external route should reuse existing hub conversation binding.
routeIncomingToManager(manager, {
messageId: "in-a2",
conversationId: "chat-a",
senderId: "user-a",
text: "alpha-2",
chatType: "group",
});
await vi.advanceTimersByTimeAsync(600);
expect(getConversationIdByExternal(manager, "chat-a")).toBe(convA);
expect((hub as unknown as { createConversation: ReturnType<typeof vi.fn> }).createConversation).toHaveBeenCalledTimes(2);
expect(harnessA?.write).toHaveBeenCalledTimes(2);
expect(harnessA?.write.mock.calls[1]?.[0]).toContain("alpha-2");
manager.stopAll();
});
it("restores route bindings from disk after manager restart", async () => {
const routeBindingsPath = join(testDir, "route-bindings.json");
const {
hub,
createManager,
routeIncomingToManager,
getConversationIdByExternal,
conversations,
} = createHarness();
const managerA = createManager(routeBindingsPath);
routeIncomingToManager(managerA, {
messageId: "in-p1",
conversationId: "chat-persist",
senderId: "user-p",
text: "persist-1",
chatType: "direct",
});
await vi.advanceTimersByTimeAsync(600);
const firstConversationId = getConversationIdByExternal(managerA, "chat-persist");
expect(firstConversationId).toBeDefined();
const harness = conversations.get(firstConversationId!);
expect(harness?.write).toHaveBeenCalledTimes(1);
managerA.stopAll();
const managerB = createManager(routeBindingsPath);
routeIncomingToManager(managerB, {
messageId: "in-p2",
conversationId: "chat-persist",
senderId: "user-p",
text: "persist-2",
chatType: "direct",
});
await vi.advanceTimersByTimeAsync(600);
const restoredConversationId = getConversationIdByExternal(managerB, "chat-persist");
expect(restoredConversationId).toBe(firstConversationId);
expect((hub as unknown as { createConversation: ReturnType<typeof vi.fn> }).createConversation).toHaveBeenCalledTimes(1);
expect(harness?.write).toHaveBeenCalledTimes(2);
expect(harness?.write.mock.calls[1]?.[0]).toContain("persist-2");
managerB.stopAll();
});
});

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,8 @@ export interface ExecApprovalRequestPayload {
approvalId: string;
/** Agent that initiated the command */
agentId: string;
/** Conversation ID that initiated the approval request. */
conversationId: string;
/** Shell command requiring approval */
command: string;
/** Working directory */

View file

@ -19,11 +19,11 @@ export {
type GetAgentMessagesParams,
type GetAgentMessagesResult,
type GetHubInfoResult,
type ListAgentsResult,
type CreateAgentParams,
type CreateAgentResult,
type DeleteAgentParams,
type DeleteAgentResult,
type ListConversationsResult,
type CreateConversationParams,
type CreateConversationResult,
type DeleteConversationParams,
type DeleteConversationResult,
type UpdateGatewayParams,
type UpdateGatewayResult,
type DeviceMeta,

View file

@ -66,6 +66,8 @@ export const DEFAULT_MESSAGES_LIMIT = 200;
/** getAgentMessages - request params */
export interface GetAgentMessagesParams {
agentId: string;
/** Conversation ID to read. */
conversationId: string;
offset?: number;
limit?: number;
}
@ -82,6 +84,10 @@ export interface GetAgentMessagesResult {
total: number;
offset: number;
limit: number;
/** Conversation ID used by the server. */
conversationId: string;
/** Context window size (tokens) used by this session */
contextWindowTokens?: number;
}
/** getHubInfo - no params needed */
@ -92,28 +98,29 @@ export interface GetHubInfoResult {
agentCount: number;
}
/** listAgents - no params needed */
export interface ListAgentsResult {
agents: { id: string; closed: boolean }[];
/** listConversations - no params needed */
export interface ListConversationsResult {
conversations: { id: string; closed: boolean }[];
}
/** createAgent - request params */
export interface CreateAgentParams {
/** createConversation - request params (create a conversation, optionally under a specific agent) */
export interface CreateConversationParams {
id?: string;
agentId?: string;
}
/** createAgent - response payload */
export interface CreateAgentResult {
/** createConversation - response payload */
export interface CreateConversationResult {
id: string;
}
/** deleteAgent - request params */
export interface DeleteAgentParams {
/** deleteConversation - request params */
export interface DeleteConversationParams {
id: string;
}
/** deleteAgent - response payload */
export interface DeleteAgentResult {
/** deleteConversation - response payload */
export interface DeleteConversationResult {
ok: boolean;
}
@ -145,4 +152,6 @@ export interface VerifyParams {
export interface VerifyResult {
hubId: string;
agentId: string;
/** Authorized conversation scope for this device. */
conversationId: string;
}

View file

@ -62,6 +62,8 @@ export type AgentErrorEvent = {
export interface StreamPayload {
streamId: string;
agentId: string;
/** Conversation ID of this stream event. */
conversationId: string;
event: AgentEvent | CompactionEvent | AgentErrorEvent;
}

View file

@ -234,7 +234,16 @@ export class GatewayClient {
}
/** Hub 验证成功回调 */
onVerified(callback: (result: { hubId: string; agentId: string; isNewDevice?: boolean }) => void): this {
onVerified(
callback: (
result: {
hubId: string;
agentId: string;
conversationId: string;
isNewDevice?: boolean;
}
) => void,
): this {
this.callbacks.onVerified = callback;
return this;
}
@ -318,12 +327,17 @@ export class GatewayClient {
platform: navigator.platform,
language: navigator.language,
} : undefined;
this.request<{ hubId: string; agentId: string; isNewDevice?: boolean }>(
this.options.hubId,
"verify",
{ token: this.options.token, meta },
this.options.verifyTimeout,
)
this.request<{
hubId: string;
agentId: string;
conversationId: string;
isNewDevice?: boolean;
}>(
this.options.hubId,
"verify",
{ token: this.options.token, meta },
this.options.verifyTimeout,
)
.then((result) => {
// Verify succeeded — now expose "registered" to upper layer
this.callbacks.onVerified?.(result);

View file

@ -110,7 +110,14 @@ export interface GatewayClientCallbacks {
onConnect?: (socketId: string) => void;
onDisconnect?: (reason: string) => void;
onRegistered?: (deviceId: string) => void;
onVerified?: (result: { hubId: string; agentId: string; isNewDevice?: boolean }) => void;
onVerified?: (
result: {
hubId: string;
agentId: string;
conversationId: string;
isNewDevice?: boolean;
}
) => void;
onMessage?: (message: RoutedMessage) => void;
onSendError?: (error: SendErrorResponse) => void;
onPong?: (data: string) => void;

View file

@ -0,0 +1,80 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("hub agent store", () => {
let testDir: string;
let previousDataDir: string | undefined;
beforeEach(() => {
testDir = mkdtempSync(join(tmpdir(), "multica-agent-store-"));
previousDataDir = process.env.SMC_DATA_DIR;
process.env.SMC_DATA_DIR = testDir;
vi.resetModules();
});
afterEach(() => {
if (previousDataDir === undefined) {
delete process.env.SMC_DATA_DIR;
} else {
process.env.SMC_DATA_DIR = previousDataDir;
}
vi.resetModules();
rmSync(testDir, { recursive: true, force: true });
});
it("migrates legacy single-layer records into agent+conversation snapshot", async () => {
const agentsDir = join(testDir, "agents");
mkdirSync(agentsDir, { recursive: true });
writeFileSync(
join(agentsDir, "agents.json"),
JSON.stringify([
{ id: "legacy-a", createdAt: 123 },
], null, 2),
"utf-8",
);
const store = await import("./agent-store.js");
const snapshot = store.loadHubStoreSnapshot();
expect(snapshot.version).toBe(2);
expect(snapshot.agents).toEqual([{ id: "legacy-a", createdAt: 123 }]);
expect(snapshot.conversations).toEqual([{ id: "legacy-a", agentId: "legacy-a", createdAt: 123 }]);
const persisted = JSON.parse(readFileSync(join(agentsDir, "agents.json"), "utf-8")) as {
version: number;
};
expect(persisted.version).toBe(2);
});
it("upserts conversations and auto-creates missing agents", async () => {
const store = await import("./agent-store.js");
store.upsertConversationRecord({
id: "conv-1",
agentId: "agent-1",
createdAt: 100,
});
const snapshot = store.loadHubStoreSnapshot();
expect(snapshot.agents).toEqual([{ id: "agent-1", createdAt: 100 }]);
expect(snapshot.conversations).toEqual([
{ id: "conv-1", agentId: "agent-1", createdAt: 100 },
]);
});
it("removes empty agent after last conversation is deleted", async () => {
const store = await import("./agent-store.js");
store.upsertConversationRecord({ id: "conv-1", agentId: "agent-1", createdAt: 100 });
store.upsertConversationRecord({ id: "conv-2", agentId: "agent-1", createdAt: 101 });
store.removeConversationRecordById("conv-1");
expect(store.loadHubStoreSnapshot().agents).toEqual([{ id: "agent-1", createdAt: 100 }]);
store.removeConversationRecordById("conv-2");
const snapshot = store.loadHubStoreSnapshot();
expect(snapshot.agents).toEqual([]);
expect(snapshot.conversations).toEqual([]);
});
});

View file

@ -5,10 +5,34 @@ import { DATA_DIR } from "@multica/utils";
export interface AgentRecord {
id: string;
createdAt: number;
profileId?: string;
}
export interface ConversationRecord {
id: string;
agentId: string;
createdAt: number;
profileId?: string;
}
export interface HubStoreSnapshot {
version: 2;
agents: AgentRecord[];
conversations: ConversationRecord[];
}
const AGENTS_DIR = join(DATA_DIR, "agents");
const AGENTS_FILE = join(AGENTS_DIR, "agents.json");
const warnedLegacyApis = new Set<string>();
function warnLegacyApi(apiName: string): void {
if (warnedLegacyApis.has(apiName)) return;
warnedLegacyApis.add(apiName);
console.warn(
`[agent-store] Deprecated legacy API "${apiName}" was used. ` +
"Migrate callers to conversation-first APIs (loadHubStoreSnapshot/upsertConversationRecord).",
);
}
function ensureDir(): void {
if (!existsSync(AGENTS_DIR)) {
@ -16,32 +40,244 @@ function ensureDir(): void {
}
}
export function loadAgentRecords(): AgentRecord[] {
if (!existsSync(AGENTS_FILE)) return [];
function defaultSnapshot(): HubStoreSnapshot {
return {
version: 2,
agents: [],
conversations: [],
};
}
function isRecordLike(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object";
}
function normalizeCreatedAt(raw: unknown): number {
if (typeof raw === "number" && Number.isFinite(raw)) {
return raw;
}
return Date.now();
}
function normalizeAgentRecords(input: unknown): AgentRecord[] {
if (!Array.isArray(input)) return [];
const dedup = new Map<string, AgentRecord>();
for (const item of input) {
if (!isRecordLike(item) || typeof item.id !== "string" || !item.id.trim()) continue;
const id = item.id.trim();
if (dedup.has(id)) continue;
dedup.set(id, {
id,
createdAt: normalizeCreatedAt(item.createdAt),
...(typeof item.profileId === "string" && item.profileId.trim() ? { profileId: item.profileId.trim() } : {}),
});
}
return Array.from(dedup.values()).sort((a, b) => a.createdAt - b.createdAt);
}
function normalizeConversationRecords(input: unknown): ConversationRecord[] {
if (!Array.isArray(input)) return [];
const dedup = new Map<string, ConversationRecord>();
for (const item of input) {
if (!isRecordLike(item) || typeof item.id !== "string" || !item.id.trim()) continue;
if (typeof item.agentId !== "string" || !item.agentId.trim()) continue;
const id = item.id.trim();
if (dedup.has(id)) continue;
dedup.set(id, {
id,
agentId: item.agentId.trim(),
createdAt: normalizeCreatedAt(item.createdAt),
...(typeof item.profileId === "string" && item.profileId.trim() ? { profileId: item.profileId.trim() } : {}),
});
}
return Array.from(dedup.values()).sort((a, b) => a.createdAt - b.createdAt);
}
function normalizeSnapshot(raw: unknown): { snapshot: HubStoreSnapshot; migrated: boolean } {
// Legacy format: AgentRecord[]
if (Array.isArray(raw)) {
const legacyAgents = normalizeAgentRecords(raw);
const conversations: ConversationRecord[] = legacyAgents.map((record) => ({
id: record.id,
agentId: record.id,
createdAt: record.createdAt,
...(record.profileId ? { profileId: record.profileId } : {}),
}));
return {
snapshot: {
version: 2,
agents: legacyAgents,
conversations,
},
migrated: true,
};
}
if (!isRecordLike(raw)) {
return { snapshot: defaultSnapshot(), migrated: false };
}
const agents = normalizeAgentRecords(raw.agents);
const conversations = normalizeConversationRecords(raw.conversations);
const agentMap = new Map<string, AgentRecord>(agents.map((agent) => [agent.id, agent]));
const normalizedConversations: ConversationRecord[] = [];
for (const conversation of conversations) {
if (!agentMap.has(conversation.agentId)) {
agentMap.set(conversation.agentId, {
id: conversation.agentId,
createdAt: conversation.createdAt,
...(conversation.profileId ? { profileId: conversation.profileId } : {}),
});
}
normalizedConversations.push(conversation);
}
// Ensure each agent has a main conversation for compatibility fallback.
for (const agent of agentMap.values()) {
const hasConversation = normalizedConversations.some((conversation) => conversation.agentId === agent.id);
if (hasConversation) continue;
normalizedConversations.push({
id: agent.id,
agentId: agent.id,
createdAt: agent.createdAt,
...(agent.profileId ? { profileId: agent.profileId } : {}),
});
}
return {
snapshot: {
version: 2,
agents: Array.from(agentMap.values()).sort((a, b) => a.createdAt - b.createdAt),
conversations: normalizedConversations.sort((a, b) => a.createdAt - b.createdAt),
},
migrated: (raw.version as unknown) !== 2,
};
}
export function saveHubStoreSnapshot(snapshot: HubStoreSnapshot): void {
ensureDir();
writeFileSync(AGENTS_FILE, JSON.stringify(snapshot, null, 2), "utf-8");
}
export function loadHubStoreSnapshot(): HubStoreSnapshot {
if (!existsSync(AGENTS_FILE)) return defaultSnapshot();
try {
const content = readFileSync(AGENTS_FILE, "utf-8");
return JSON.parse(content) as AgentRecord[];
const parsed = JSON.parse(content) as unknown;
const normalized = normalizeSnapshot(parsed);
if (normalized.migrated) {
saveHubStoreSnapshot(normalized.snapshot);
}
return normalized.snapshot;
} catch {
return [];
return defaultSnapshot();
}
}
export function upsertAgentRecord(record: AgentRecord): void {
const snapshot = loadHubStoreSnapshot();
const existing = snapshot.agents.filter((item) => item.id !== record.id);
existing.push(record);
snapshot.agents = existing.sort((a, b) => a.createdAt - b.createdAt);
saveHubStoreSnapshot(snapshot);
}
export function removeAgentRecordById(agentId: string): void {
const snapshot = loadHubStoreSnapshot();
const agents = snapshot.agents.filter((agent) => agent.id !== agentId);
const conversations = snapshot.conversations.filter((conversation) => conversation.agentId !== agentId);
if (agents.length === snapshot.agents.length && conversations.length === snapshot.conversations.length) {
return;
}
saveHubStoreSnapshot({
...snapshot,
agents,
conversations,
});
}
export function upsertConversationRecord(record: ConversationRecord): void {
const snapshot = loadHubStoreSnapshot();
const conversations = snapshot.conversations.filter((item) => item.id !== record.id);
conversations.push(record);
const hasAgent = snapshot.agents.some((agent) => agent.id === record.agentId);
const agents = hasAgent
? snapshot.agents
: [
...snapshot.agents,
{
id: record.agentId,
createdAt: record.createdAt,
...(record.profileId ? { profileId: record.profileId } : {}),
},
];
saveHubStoreSnapshot({
version: 2,
agents: agents.sort((a, b) => a.createdAt - b.createdAt),
conversations: conversations.sort((a, b) => a.createdAt - b.createdAt),
});
}
export function removeConversationRecordById(conversationId: string): void {
const snapshot = loadHubStoreSnapshot();
const conversations = snapshot.conversations.filter((conversation) => conversation.id !== conversationId);
if (conversations.length === snapshot.conversations.length) {
return;
}
const activeAgentIds = new Set(conversations.map((conversation) => conversation.agentId));
const agents = snapshot.agents.filter((agent) => activeAgentIds.has(agent.id));
saveHubStoreSnapshot({
...snapshot,
agents,
conversations,
});
}
// Legacy compatibility wrappers
// NOTE: In legacy mode, each agent record is treated as both agent and main conversation.
export function loadAgentRecords(): AgentRecord[] {
warnLegacyApi("loadAgentRecords");
return loadHubStoreSnapshot().agents;
}
export function saveAgentRecords(records: AgentRecord[]): void {
ensureDir();
writeFileSync(AGENTS_FILE, JSON.stringify(records, null, 2), "utf-8");
warnLegacyApi("saveAgentRecords");
const agents = normalizeAgentRecords(records);
const conversations = agents.map((record) => ({
id: record.id,
agentId: record.id,
createdAt: record.createdAt,
...(record.profileId ? { profileId: record.profileId } : {}),
}));
saveHubStoreSnapshot({
version: 2,
agents,
conversations,
});
}
export function addAgentRecord(record: AgentRecord): void {
const records = loadAgentRecords();
if (records.some((r) => r.id === record.id)) return;
records.push(record);
saveAgentRecords(records);
warnLegacyApi("addAgentRecord");
upsertAgentRecord(record);
upsertConversationRecord({
id: record.id,
agentId: record.id,
createdAt: record.createdAt,
...(record.profileId ? { profileId: record.profileId } : {}),
});
}
export function removeAgentRecord(id: string): void {
const records = loadAgentRecords();
const filtered = records.filter((r) => r.id !== id);
if (filtered.length !== records.length) {
saveAgentRecords(filtered);
warnLegacyApi("removeAgentRecord");
// Legacy API accepts either agent id or conversation id.
const snapshot = loadHubStoreSnapshot();
const conversation = snapshot.conversations.find((item) => item.id === id);
if (conversation) {
removeConversationRecordById(conversation.id);
}
removeAgentRecordById(id);
}

View file

@ -0,0 +1,87 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { DeviceStore } from "./device-store.js";
describe("DeviceStore", () => {
const testDirs: string[] = [];
afterEach(() => {
for (const dir of testDirs) {
rmSync(dir, { recursive: true, force: true });
}
testDirs.length = 0;
});
it("stores token with conversation scope and enforces one-time consumption", () => {
const dir = mkdtempSync(join(tmpdir(), "device-store-test-"));
testDirs.push(dir);
const store = new DeviceStore({ devicesFile: join(dir, "whitelist.json") });
const expiresAt = Date.now() + 60_000;
store.registerToken("token-1", "agent-1", "conv-1", expiresAt);
expect(store.consumeToken("token-1")).toEqual({
agentId: "agent-1",
conversationId: "conv-1",
});
expect(store.consumeToken("token-1")).toBeNull();
});
it("enforces conversation-level authorization and supports adding scopes", () => {
const dir = mkdtempSync(join(tmpdir(), "device-store-test-"));
testDirs.push(dir);
const devicesFile = join(dir, "whitelist.json");
const store = new DeviceStore({ devicesFile });
store.allowDevice("dev-1", "agent-1", "conv-1");
expect(store.isAllowed("dev-1")).toEqual({
agentId: "agent-1",
conversationIds: ["conv-1"],
});
expect(store.isAllowed("dev-1", "conv-1")).toEqual({
agentId: "agent-1",
conversationIds: ["conv-1"],
});
expect(store.isAllowed("dev-1", "conv-2")).toBeNull();
expect(store.allowConversation("dev-1", "conv-2")).toBe(true);
expect(store.isAllowed("dev-1", "conv-2")).toEqual({
agentId: "agent-1",
conversationIds: ["conv-1", "conv-2"],
});
const restored = new DeviceStore({ devicesFile });
expect(restored.isAllowed("dev-1", "conv-1")).not.toBeNull();
expect(restored.isAllowed("dev-1", "conv-2")).not.toBeNull();
});
it("migrates legacy entries without conversationIds using agentId as fallback scope", () => {
const dir = mkdtempSync(join(tmpdir(), "device-store-test-"));
testDirs.push(dir);
const devicesFile = join(dir, "whitelist.json");
writeFileSync(
devicesFile,
JSON.stringify({
version: 1,
devices: [
{
deviceId: "legacy-dev",
agentId: "legacy-agent",
addedAt: 123,
},
],
}),
"utf-8",
);
const store = new DeviceStore({ devicesFile });
expect(store.isAllowed("legacy-dev")).toEqual({
agentId: "legacy-agent",
conversationIds: ["legacy-agent"],
});
expect(store.isAllowed("legacy-dev", "legacy-agent")).not.toBeNull();
});
});

View file

@ -1,5 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { DATA_DIR } from "@multica/utils";
// ============ Types ============
@ -7,6 +7,7 @@ import { DATA_DIR } from "@multica/utils";
interface TokenEntry {
token: string;
agentId: string;
conversationId: string;
expiresAt: number;
}
@ -20,6 +21,7 @@ export interface DeviceMeta {
export interface DeviceEntry {
deviceId: string;
agentId: string;
conversationIds: string[];
addedAt: number;
meta?: DeviceMeta | undefined;
}
@ -34,41 +36,44 @@ interface WhitelistFile {
const DEVICES_DIR = join(DATA_DIR, "client-devices");
const DEVICES_FILE = join(DEVICES_DIR, "whitelist.json");
function ensureDir(): void {
if (!existsSync(DEVICES_DIR)) {
mkdirSync(DEVICES_DIR, { recursive: true });
}
interface DeviceStoreOptions {
devicesFile?: string;
}
function loadDevices(): DeviceEntry[] {
if (!existsSync(DEVICES_FILE)) return [];
try {
const raw = JSON.parse(readFileSync(DEVICES_FILE, "utf-8"));
// Migrate legacy array format
if (Array.isArray(raw)) return raw as DeviceEntry[];
return (raw as WhitelistFile).devices ?? [];
} catch {
return [];
function normalizeConversationIds(
input: unknown,
fallbackConversationId: string,
): string[] {
const ids = new Set<string>();
if (Array.isArray(input)) {
for (const item of input) {
if (typeof item !== "string") continue;
const id = item.trim();
if (id) ids.add(id);
}
}
}
function saveDevices(devices: DeviceEntry[]): void {
ensureDir();
const data: WhitelistFile = { version: 1, devices };
writeFileSync(DEVICES_FILE, JSON.stringify(data, null, 2), "utf-8");
const fallback = fallbackConversationId.trim();
if (ids.size === 0 && fallback) {
ids.add(fallback);
}
return Array.from(ids);
}
// ============ DeviceStore ============
export class DeviceStore {
private readonly devicesDir: string;
private readonly devicesFile: string;
/** One-time tokens (in-memory only, not persisted) */
private readonly tokens = new Map<string, TokenEntry>();
/** Allowed device IDs (persisted to disk) */
private readonly allowedDevices = new Map<string, DeviceEntry>();
constructor() {
constructor(options?: DeviceStoreOptions) {
this.devicesFile = options?.devicesFile ?? DEVICES_FILE;
this.devicesDir = options?.devicesFile ? dirname(options.devicesFile) : DEVICES_DIR;
// Restore from persistent storage
for (const entry of loadDevices()) {
for (const entry of this.loadDevices()) {
this.allowedDevices.set(entry.deviceId, entry);
}
}
@ -76,38 +81,75 @@ export class DeviceStore {
// ---- Token management ----
/** Register a one-time token (called when QR code is generated) */
registerToken(token: string, agentId: string, expiresAt: number): void {
registerToken(token: string, agentId: string, conversationId: string, expiresAt: number): void {
// Clean up expired tokens to prevent accumulation
const now = Date.now();
for (const [key, entry] of this.tokens) {
if (now > entry.expiresAt) this.tokens.delete(key);
}
this.tokens.set(token, { token, agentId, expiresAt });
this.tokens.set(token, { token, agentId, conversationId, expiresAt });
}
/** Validate and consume a token (one-time use). Returns agentId if valid, null otherwise. */
consumeToken(token: string): { agentId: string } | null {
consumeToken(token: string): { agentId: string; conversationId: string } | null {
const entry = this.tokens.get(token);
if (!entry) return null;
// Always delete — consumed or expired
this.tokens.delete(token);
if (Date.now() > entry.expiresAt) return null;
return { agentId: entry.agentId };
return { agentId: entry.agentId, conversationId: entry.conversationId };
}
// ---- Device whitelist ----
/** Add a device to the whitelist (called after token verification + user confirmation) */
allowDevice(deviceId: string, agentId: string, meta?: DeviceMeta): void {
const entry: DeviceEntry = { deviceId, agentId, addedAt: Date.now(), meta };
allowDevice(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta): void {
const existing = this.allowedDevices.get(deviceId);
const conversationIds = existing && existing.agentId === agentId
? normalizeConversationIds(existing.conversationIds, conversationId)
: normalizeConversationIds([], conversationId);
if (!conversationIds.includes(conversationId)) {
conversationIds.push(conversationId);
}
const entry: DeviceEntry = {
deviceId,
agentId,
conversationIds,
addedAt: existing?.addedAt ?? Date.now(),
meta,
};
this.allowedDevices.set(deviceId, entry);
this.persist();
}
/** Check if a device is in the whitelist */
isAllowed(deviceId: string): { agentId: string } | null {
isAllowed(
deviceId: string,
conversationId?: string,
): { agentId: string; conversationIds: string[] } | null {
const entry = this.allowedDevices.get(deviceId);
return entry ? { agentId: entry.agentId } : null;
if (!entry) return null;
if (conversationId !== undefined && !entry.conversationIds.includes(conversationId)) {
return null;
}
return {
agentId: entry.agentId,
conversationIds: [...entry.conversationIds],
};
}
/** Grant an additional conversation scope to an existing device. */
allowConversation(deviceId: string, conversationId: string): boolean {
const entry = this.allowedDevices.get(deviceId);
if (!entry) return false;
if (entry.conversationIds.includes(conversationId)) return true;
entry.conversationIds.push(conversationId);
this.allowedDevices.set(deviceId, entry);
this.persist();
return true;
}
/** Remove a device from the whitelist */
@ -123,6 +165,56 @@ export class DeviceStore {
}
private persist(): void {
saveDevices(Array.from(this.allowedDevices.values()));
this.saveDevices(Array.from(this.allowedDevices.values()));
}
private ensureDir(): void {
if (!existsSync(this.devicesDir)) {
mkdirSync(this.devicesDir, { recursive: true });
}
}
private loadDevices(): DeviceEntry[] {
if (!existsSync(this.devicesFile)) return [];
try {
const raw = JSON.parse(readFileSync(this.devicesFile, "utf-8"));
const devices = Array.isArray(raw) ? raw : (raw as WhitelistFile).devices ?? [];
if (!Array.isArray(devices)) return [];
const normalized: DeviceEntry[] = [];
for (const item of devices) {
if (!item || typeof item !== "object") continue;
const rawDeviceId = (item as { deviceId?: unknown }).deviceId;
const rawAgentId = (item as { agentId?: unknown }).agentId;
if (typeof rawDeviceId !== "string" || typeof rawAgentId !== "string") continue;
const deviceId = rawDeviceId.trim();
const agentId = rawAgentId.trim();
if (!deviceId || !agentId) continue;
const fallbackConversationId = typeof (item as { conversationId?: unknown }).conversationId === "string"
? (item as { conversationId: string }).conversationId
: agentId;
normalized.push({
deviceId,
agentId,
conversationIds: normalizeConversationIds(
(item as { conversationIds?: unknown }).conversationIds,
fallbackConversationId,
),
addedAt: typeof (item as { addedAt?: unknown }).addedAt === "number"
? (item as { addedAt: number }).addedAt
: Date.now(),
meta: (item as { meta?: DeviceMeta }).meta,
});
}
return normalized;
} catch {
return [];
}
}
private saveDevices(devices: DeviceEntry[]): void {
this.ensureDir();
const data: WhitelistFile = { version: 2, devices };
writeFileSync(this.devicesFile, JSON.stringify(data, null, 2), "utf-8");
}
}

View file

@ -18,6 +18,7 @@ describe("ExecApprovalManager", () => {
it("sends approval request to client and resolves on decision", async () => {
const promise = manager.requestApproval({
agentId: "agent-1",
conversationId: "agent-1",
command: "rm -rf /tmp/test",
cwd: "/workspace",
riskLevel: "dangerous",
@ -43,6 +44,7 @@ describe("ExecApprovalManager", () => {
it("resolves with deny when decision is deny", async () => {
const promise = manager.requestApproval({
agentId: "agent-1",
conversationId: "agent-1",
command: "sudo reboot",
riskLevel: "dangerous",
riskReasons: [],
@ -59,6 +61,7 @@ describe("ExecApprovalManager", () => {
it("resolves with allow-always", async () => {
const promise = manager.requestApproval({
agentId: "agent-1",
conversationId: "agent-1",
command: "git push",
riskLevel: "needs-review",
riskReasons: [],
@ -75,6 +78,7 @@ describe("ExecApprovalManager", () => {
it("auto-denies on timeout (fail-closed)", async () => {
const promise = manager.requestApproval({
agentId: "agent-1",
conversationId: "agent-1",
command: "dangerous-command",
riskLevel: "dangerous",
riskReasons: [],
@ -91,6 +95,7 @@ describe("ExecApprovalManager", () => {
it("keeps approval pending indefinitely when timeoutMs is -1", async () => {
const promise = manager.requestApproval({
agentId: "agent-1",
conversationId: "agent-1",
command: "cmd",
riskLevel: "needs-review",
riskReasons: [],
@ -112,6 +117,7 @@ describe("ExecApprovalManager", () => {
it("honors askFallback full on timeout", async () => {
const promise = manager.requestApproval({
agentId: "agent-1",
conversationId: "agent-1",
command: "cmd",
riskLevel: "needs-review",
riskReasons: [],
@ -128,6 +134,7 @@ describe("ExecApprovalManager", () => {
it("honors askFallback allowlist on timeout", async () => {
const allowPromise = manager.requestApproval({
agentId: "agent-1",
conversationId: "agent-1",
command: "cmd",
riskLevel: "needs-review",
riskReasons: [],
@ -143,6 +150,7 @@ describe("ExecApprovalManager", () => {
const denyPromise = manager.requestApproval({
agentId: "agent-1",
conversationId: "agent-1",
command: "cmd",
riskLevel: "needs-review",
riskReasons: [],
@ -165,6 +173,7 @@ describe("ExecApprovalManager", () => {
it("returns false when resolving already-resolved approval", async () => {
const promise = manager.requestApproval({
agentId: "agent-1",
conversationId: "agent-1",
command: "cmd",
riskLevel: "needs-review",
riskReasons: [],
@ -183,6 +192,7 @@ describe("ExecApprovalManager", () => {
it("cancels all pending approvals for an agent", async () => {
const promise1 = manager.requestApproval({
agentId: "agent-1",
conversationId: "agent-1",
command: "cmd1",
riskLevel: "needs-review",
riskReasons: [],
@ -190,6 +200,7 @@ describe("ExecApprovalManager", () => {
const promise2 = manager.requestApproval({
agentId: "agent-1",
conversationId: "agent-1",
command: "cmd2",
riskLevel: "needs-review",
riskReasons: [],
@ -197,6 +208,7 @@ describe("ExecApprovalManager", () => {
const promise3 = manager.requestApproval({
agentId: "agent-2",
conversationId: "agent-2",
command: "cmd3",
riskLevel: "needs-review",
riskReasons: [],
@ -231,6 +243,7 @@ describe("ExecApprovalManager", () => {
const result = await failManager.requestApproval({
agentId: "agent-1",
conversationId: "agent-1",
command: "cmd",
riskLevel: "needs-review",
riskReasons: [],
@ -243,6 +256,7 @@ describe("ExecApprovalManager", () => {
it("getSnapshot returns request details", () => {
manager.requestApproval({
agentId: "agent-1",
conversationId: "agent-1",
command: "ls",
riskLevel: "safe",
riskReasons: [],
@ -265,6 +279,7 @@ describe("ExecApprovalManager", () => {
manager.requestApproval({
agentId: "agent-1",
conversationId: "agent-1",
command: "cmd1",
riskLevel: "needs-review",
riskReasons: [],
@ -273,6 +288,7 @@ describe("ExecApprovalManager", () => {
manager.requestApproval({
agentId: "agent-1",
conversationId: "agent-1",
command: "cmd2",
riskLevel: "needs-review",
riskReasons: [],

View file

@ -24,7 +24,7 @@ interface PendingEntry {
* The Hub wires this to Gateway message sending.
*/
export type SendApprovalToClient = (
agentId: string,
conversationId: string,
payload: ExecApprovalRequest,
) => void;
@ -42,6 +42,7 @@ export class ExecApprovalManager {
*/
requestApproval(params: {
agentId: string;
conversationId: string;
command: string;
cwd?: string;
riskLevel: "safe" | "needs-review" | "dangerous";
@ -53,10 +54,12 @@ export class ExecApprovalManager {
const approvalId = uuidv7();
const timeoutMs = params.timeoutMs ?? this.defaultTimeoutMs;
const expiresAtMs = timeoutMs >= 0 ? Date.now() + timeoutMs : -1;
const conversationId = params.conversationId;
const request: ExecApprovalRequest = {
approvalId,
agentId: params.agentId,
conversationId,
command: params.command,
cwd: params.cwd,
riskLevel: params.riskLevel,
@ -85,7 +88,7 @@ export class ExecApprovalManager {
// Send to client via Gateway
try {
this.sendToClient(params.agentId, request);
this.sendToClient(conversationId, request);
} catch (err) {
// If sending fails, auto-deny (fail-closed)
if (timer) clearTimeout(timer);
@ -121,7 +124,10 @@ export class ExecApprovalManager {
*/
cancelPending(agentId: string): void {
for (const [id, entry] of this.pending) {
if (entry.request.agentId === agentId) {
if (
entry.request.agentId === agentId
|| entry.request.conversationId === agentId
) {
if (entry.timer) clearTimeout(entry.timer);
this.pending.delete(id);
entry.resolve({ approved: false, decision: "deny" });

View file

@ -17,13 +17,19 @@ import { AsyncAgent } from "../agent/async-agent.js";
import type { AgentOptions } from "../agent/types.js";
import { getHubId } from "./hub-identity.js";
import { setHub } from "./hub-singleton.js";
import { loadAgentRecords, addAgentRecord, removeAgentRecord } from "./agent-store.js";
import {
loadHubStoreSnapshot,
upsertAgentRecord,
upsertConversationRecord,
removeConversationRecordById,
removeAgentRecordById,
} from "./agent-store.js";
import { RpcDispatcher, RpcError } from "./rpc/dispatcher.js";
import { createGetAgentMessagesHandler } from "./rpc/handlers/get-agent-messages.js";
import { createGetHubInfoHandler } from "./rpc/handlers/get-hub-info.js";
import { createListAgentsHandler } from "./rpc/handlers/list-agents.js";
import { createCreateAgentHandler } from "./rpc/handlers/create-agent.js";
import { createDeleteAgentHandler } from "./rpc/handlers/delete-agent.js";
import { createListConversationsHandler } from "./rpc/handlers/list-conversations.js";
import { createCreateConversationHandler } from "./rpc/handlers/create-conversation.js";
import { createDeleteConversationHandler } from "./rpc/handlers/delete-conversation.js";
import { createUpdateGatewayHandler } from "./rpc/handlers/update-gateway.js";
import { createGetLastHeartbeatHandler } from "./rpc/handlers/get-last-heartbeat.js";
import { createSetHeartbeatsHandler } from "./rpc/handlers/set-heartbeats.js";
@ -64,17 +70,26 @@ export type MessageSource =
/** Inbound message event broadcast to all listeners */
export interface InboundMessageEvent {
agentId: string;
/** Conversation ID for this inbound message. */
conversationId: string;
content: string;
source: MessageSource;
timestamp: number;
}
export class Hub {
// Runtime conversation map (conversationId -> AsyncAgent).
private readonly agents = new Map<string, AsyncAgent>();
// Conversation ownership map (conversationId -> logical agentId).
private readonly conversationAgents = new Map<string, string>();
// Main conversation pointer for each agent (agentId -> mainConversationId).
private readonly agentMainConversations = new Map<string, string>();
// Runtime profile for each logical agent.
private readonly agentProfiles = new Map<string, string>();
private readonly agentSenders = new Map<string, string>();
private readonly agentStreamIds = new Map<string, string>();
private readonly agentStreamCounters = new Map<string, number>();
private readonly pendingAssistantStarts = new Map<string, { agentId: string; event: unknown }>();
private readonly pendingAssistantStarts = new Map<string, { agentId: string; conversationId: string; event: unknown }>();
private readonly suppressedStreamAgents = new Set<string>();
private readonly localApprovalHandlers = new Map<string, (payload: ExecApprovalRequest) => void>();
private readonly inboundListeners = new Set<(event: InboundMessageEvent) => void>();
@ -85,7 +100,9 @@ export class Hub {
private heartbeatUnsubscribe: (() => void) | null = null;
private client: GatewayClient;
readonly deviceStore: DeviceStore;
private _onConfirmDevice: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise<boolean>) | null = null;
private _onConfirmDevice: (
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => Promise<boolean>
) | null = null;
private _stateChangeListeners: ((state: ConnectionState) => void)[] = [];
readonly channelManager: ChannelManager;
url: string;
@ -107,37 +124,45 @@ export class Hub {
this.rpc.register("verify", createVerifyHandler({
hubId: this.hubId,
deviceStore: this.deviceStore,
onConfirmDevice: (deviceId, agentId, meta) => {
resolveMainConversationId: (agentId) => this.getAgentMainConversationId(agentId),
onConfirmDevice: (deviceId, agentId, conversationId, meta) => {
if (!this._onConfirmDevice) {
// No UI confirm handler registered (CLI mode etc.) — auto-approve
return Promise.resolve(true);
}
return this._onConfirmDevice(deviceId, agentId, meta);
return this._onConfirmDevice(deviceId, agentId, conversationId, meta);
},
}));
this.rpc.register("generateChannelWelcome", createGenerateChannelWelcomeHandler(this));
this.rpc.register("getAgentMessages", createGetAgentMessagesHandler());
this.rpc.register("getAgentMessages", createGetAgentMessagesHandler((agentId, conversationId) => {
const resolvedConversationId = this.normalizeId(conversationId);
if (!resolvedConversationId) return null;
return {
conversationId: resolvedConversationId,
storageAgentId: this.getConversationAgentId(resolvedConversationId) ?? this.normalizeId(agentId),
};
}));
this.rpc.register("getHubInfo", createGetHubInfoHandler(this));
this.rpc.register("listAgents", createListAgentsHandler(this));
this.rpc.register("createAgent", createCreateAgentHandler(this));
this.rpc.register("deleteAgent", createDeleteAgentHandler(this));
this.rpc.register("listConversations", createListConversationsHandler(this));
this.rpc.register("createConversation", createCreateConversationHandler(this));
this.rpc.register("deleteConversation", createDeleteConversationHandler(this));
this.rpc.register("updateGateway", createUpdateGatewayHandler(this));
this.rpc.register("last-heartbeat", createGetLastHeartbeatHandler(this));
this.rpc.register("set-heartbeats", createSetHeartbeatsHandler(this));
this.rpc.register("wake-heartbeat", createWakeHeartbeatHandler(this));
// Initialize exec approval manager
this.approvalManager = new ExecApprovalManager((agentId, payload) => {
this.approvalManager = new ExecApprovalManager((conversationId, payload) => {
// Check local IPC handler first (for desktop direct chat)
const localHandler = this.localApprovalHandlers.get(agentId);
const localHandler = this.localApprovalHandlers.get(conversationId);
if (localHandler) {
localHandler(payload);
return;
}
// Remote: send via Gateway
const targetDeviceId = this.agentSenders.get(agentId);
const targetDeviceId = this.agentSenders.get(conversationId);
if (!targetDeviceId) {
throw new Error(`No client device found for agent ${agentId}`);
throw new Error(`No client device found for conversation ${conversationId}`);
}
this.client.send(targetDeviceId, "exec-approval-request", payload);
});
@ -199,20 +224,144 @@ export class Hub {
}
private getDefaultAgent(): AsyncAgent | null {
const first = this.listAgents()[0];
if (!first) return null;
return this.getAgent(first) ?? null;
const firstConversationId = this.listConversations()[0];
if (!firstConversationId) return null;
return this.getConversation(firstConversationId) ?? null;
}
/** Restore agents from persistent storage */
private restoreAgents(): void {
const records = loadAgentRecords();
for (const record of records) {
this.createAgent(record.id, { persist: false });
const snapshot = loadHubStoreSnapshot();
for (const agent of snapshot.agents) {
this.agentProfiles.set(agent.id, agent.profileId ?? "default");
}
if (records.length > 0) {
console.log(`[Hub] Restored ${records.length} agent(s)`);
for (const conversation of snapshot.conversations) {
this.createConversation(conversation.id, {
agentId: conversation.agentId,
profileId: conversation.profileId ?? this.agentProfiles.get(conversation.agentId) ?? "default",
persist: false,
createdAt: conversation.createdAt,
isMainConversation: !this.agentMainConversations.has(conversation.agentId),
});
}
if (snapshot.conversations.length > 0) {
console.log(
`[Hub] Restored ${snapshot.agents.length} agent(s), ${snapshot.conversations.length} conversation(s)`,
);
}
}
private normalizeId(value: string | undefined): string | undefined {
const normalized = (value ?? "").trim();
return normalized || undefined;
}
private listConversationIdsForAgent(agentId: string): string[] {
const ids: string[] = [];
for (const [conversationId, ownerAgentId] of this.conversationAgents.entries()) {
const runtime = this.agents.get(conversationId);
if (ownerAgentId === agentId && runtime && !runtime.closed) {
ids.push(conversationId);
}
}
return ids;
}
private resolveAgentMainConversationId(agentId: string): string | undefined {
const main = this.agentMainConversations.get(agentId);
if (main) {
const runtime = this.agents.get(main);
if (runtime && !runtime.closed) {
return main;
}
}
const fallback = this.listConversationIdsForAgent(agentId)[0];
if (!fallback) return undefined;
this.agentMainConversations.set(agentId, fallback);
return fallback;
}
private resolveAgentId(agentId: string | undefined, conversationId: string): string {
const explicitAgentId = this.normalizeId(agentId);
if (explicitAgentId && this.agentMainConversations.has(explicitAgentId)) {
return explicitAgentId;
}
if (explicitAgentId && this.conversationAgents.has(explicitAgentId)) {
return this.conversationAgents.get(explicitAgentId) ?? explicitAgentId;
}
const owner = this.conversationAgents.get(conversationId);
if (owner) return owner;
return explicitAgentId ?? conversationId;
}
private resolveTargetAgentId(agentId: string | undefined, fallbackConversationId: string): string {
const normalized = this.normalizeId(agentId);
if (normalized) return normalized;
const firstAgentId = this.listAgents()[0];
return firstAgentId ?? fallbackConversationId;
}
private registerAgent(
agentId: string,
options: { profileId: string; createdAt: number; persist: boolean },
): void {
const exists = this.agentProfiles.has(agentId);
if (exists) {
const currentProfileId = this.agentProfiles.get(agentId);
if (currentProfileId !== options.profileId) {
this.agentProfiles.set(agentId, options.profileId);
}
return;
}
this.agentProfiles.set(agentId, options.profileId);
if (options.persist) {
upsertAgentRecord({
id: agentId,
createdAt: options.createdAt,
profileId: options.profileId,
});
}
}
private clearAgentIfNoConversation(agentId: string): void {
const remaining = this.listConversationIdsForAgent(agentId);
if (remaining.length > 0) {
if (!this.agentMainConversations.get(agentId)) {
this.agentMainConversations.set(agentId, remaining[0]!);
}
return;
}
this.agentMainConversations.delete(agentId);
this.agentProfiles.delete(agentId);
removeAgentRecordById(agentId);
}
private closeConversationRuntime(conversationId: string, options?: { persist?: boolean }): { ok: boolean; agentId?: string } {
const runtime = this.agents.get(conversationId);
if (!runtime) return { ok: false };
const agentId = this.conversationAgents.get(conversationId) ?? conversationId;
runtime.close();
this.approvalManager.cancelPending(conversationId);
this.agents.delete(conversationId);
this.conversationAgents.delete(conversationId);
this.agentSenders.delete(conversationId);
this.agentStreamIds.delete(conversationId);
this.agentStreamCounters.delete(conversationId);
this.clearPendingAssistantStarts(conversationId);
this.suppressedStreamAgents.delete(conversationId);
this.localApprovalHandlers.delete(conversationId);
if (options?.persist !== false) {
removeConversationRecordById(conversationId);
}
return { ok: true, agentId };
}
private createClient(url: string): GatewayClient {
@ -265,38 +414,79 @@ export class Hub {
}
// Non-RPC messages also require verified device
const payload = msg.payload as {
agentId?: string;
conversationId?: string;
content?: string;
} | undefined;
if (!this.deviceStore.isAllowed(msg.from)) {
console.warn(`[Hub] Rejected message from unverified device: ${msg.from}`);
this.client.send(msg.from, "error", {
code: "UNAUTHORIZED",
message: "Device not verified. Please complete verification first.",
messageId: msg.id,
...(payload?.conversationId ? { conversationId: payload.conversationId } : {}),
});
return;
}
const inboundConversationId = this.normalizeId(payload?.conversationId);
if (!inboundConversationId) {
this.client.send(msg.from, "error", {
code: "INVALID_PARAMS",
message: "Missing required conversationId.",
messageId: msg.id,
});
return;
}
const incomingAgentId = payload?.agentId;
const conversationId = inboundConversationId;
const agentId = this.resolveAgentId(incomingAgentId, conversationId);
const content = payload?.content;
if (!content) {
console.warn("[Hub] Invalid payload, missing content");
return;
}
const allowedScope = this.deviceStore.isAllowed(msg.from, conversationId);
if (!allowedScope) {
console.warn(`[Hub] Rejected message outside authorized conversation scope: ${msg.from} -> ${conversationId}`);
this.client.send(msg.from, "error", {
code: "UNAUTHORIZED",
message: "Device is not authorized for this conversation.",
messageId: msg.id,
conversationId,
});
return;
}
// Regular chat message
const payload = msg.payload as { agentId?: string; content?: string } | undefined;
const agentId = payload?.agentId;
const content = payload?.content;
if (!agentId || !content) {
console.warn(`[Hub] Invalid payload, missing agentId or content`);
if (allowedScope.agentId !== agentId) {
console.warn(
`[Hub] Rejected message due to agent mismatch: device=${msg.from}, allowedAgent=${allowedScope.agentId}, targetAgent=${agentId}`,
);
this.client.send(msg.from, "error", {
code: "UNAUTHORIZED",
message: "Device is not authorized for this agent.",
messageId: msg.id,
conversationId,
});
return;
}
const agent = this.agents.get(agentId);
const agent = this.agents.get(conversationId);
if (agent && !agent.closed) {
this.agentSenders.set(agentId, msg.from);
this.agentSenders.set(conversationId, msg.from);
this.channelManager.clearLastRoute();
const source: MessageSource = { type: "gateway", deviceId: msg.from };
this.broadcastInbound({
agentId,
conversationId,
content,
source,
timestamp: Date.now(),
});
agent.write(content, { source });
} else {
console.warn(`[Hub] Agent not found or closed: ${agentId}`);
console.warn(`[Hub] Conversation not found or closed: ${conversationId} (agent=${incomingAgentId})`);
}
});
@ -308,7 +498,11 @@ export class Hub {
}
/** Register a confirmation handler for new device connections (called by Desktop UI) */
setConfirmHandler(handler: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise<boolean>) | null): void {
setConfirmHandler(
handler: (
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => Promise<boolean>
) | null,
): void {
this._onConfirmDevice = handler;
}
@ -337,8 +531,20 @@ export class Hub {
}
/** Register a one-time token for device verification (called when QR code is generated) */
registerToken(token: string, agentId: string, expiresAt: number): void {
this.deviceStore.registerToken(token, agentId, expiresAt);
registerToken(token: string, agentId: string, conversationId: string, expiresAt: number): void {
const normalizedAgentId = this.normalizeId(agentId);
const normalizedConversationId = this.normalizeId(conversationId);
if (!normalizedAgentId || !normalizedConversationId) return;
const ownerAgentId = this.conversationAgents.get(normalizedConversationId);
if (ownerAgentId && ownerAgentId !== normalizedAgentId) {
console.warn(
`[Hub] registerToken rejected due to agent/conversation mismatch: agent=${normalizedAgentId}, conversation=${normalizedConversationId}, owner=${ownerAgentId}`,
);
return;
}
const resolvedAgentId = ownerAgentId ?? normalizedAgentId;
this.deviceStore.registerToken(token, resolvedAgentId, normalizedConversationId, expiresAt);
}
/** 重连到新的 Gateway 地址 */
@ -365,33 +571,96 @@ export class Hub {
return this.approvalManager.resolveApproval(approvalId, decision);
}
/** Create new Agent, or rebuild with existing ID */
createAgent(id?: string, options?: { persist?: boolean; profileId?: string }): AsyncAgent {
if (id) {
const existing = this.agents.get(id);
/** Create a logical agent and its main conversation runtime. */
createAgent(
id?: string,
options?: { persist?: boolean; profileId?: string; mainConversationId?: string; createdAt?: number },
): AsyncAgent {
const agentId = this.normalizeId(id) ?? uuidv7();
const existingMainConversationId = this.resolveAgentMainConversationId(agentId);
if (existingMainConversationId) {
const existing = this.agents.get(existingMainConversationId);
if (existing && !existing.closed) {
return existing;
}
}
const profileId = options?.profileId ?? "default";
const sessionId = id ?? uuidv7();
const onExecApprovalNeeded = this.createExecApprovalCallback(sessionId, profileId);
const onChannelSendFile = this.createChannelSendFileCallback(sessionId);
const channels = this.channelManager.listChannelInfos();
const agent = new AsyncAgent({ sessionId, profileId, onExecApprovalNeeded, onChannelSendFile, channels });
this.agents.set(agent.sessionId, agent);
const mainConversationId = this.normalizeId(options?.mainConversationId) ?? agentId;
return this.createConversation(mainConversationId, {
agentId,
profileId: options?.profileId,
persist: options?.persist,
isMainConversation: true,
createdAt: options?.createdAt,
});
}
// Persist to agent store (skip during restore to avoid duplicates)
if (options?.persist !== false) {
addAgentRecord({ id: agent.sessionId, createdAt: Date.now() });
/**
* Create a new conversation runtime.
*
* Semantics:
* - Agent = long-lived capability/profile identity
* - Conversation = isolated runtime/session thread
*/
createConversation(
id?: string,
options?: {
persist?: boolean;
profileId?: string;
agentId?: string;
isMainConversation?: boolean;
createdAt?: number;
},
): AsyncAgent {
const conversationId = this.normalizeId(id) ?? uuidv7();
const existing = this.agents.get(conversationId);
if (existing && !existing.closed) {
return existing;
}
const targetAgentId = this.resolveTargetAgentId(options?.agentId, conversationId);
const profileId = options?.profileId ?? this.agentProfiles.get(targetAgentId) ?? "default";
const createdAt = options?.createdAt ?? Date.now();
const persist = options?.persist !== false;
this.registerAgent(targetAgentId, {
profileId,
createdAt,
persist,
});
const onExecApprovalNeeded = this.createExecApprovalCallback(conversationId, targetAgentId, profileId);
const onChannelSendFile = this.createChannelSendFileCallback(conversationId);
const channels = this.channelManager.listChannelInfos();
const agent = new AsyncAgent({
sessionId: conversationId,
ownerAgentId: targetAgentId,
profileId,
onExecApprovalNeeded,
onChannelSendFile,
channels,
});
this.agents.set(conversationId, agent);
this.conversationAgents.set(conversationId, targetAgentId);
if (options?.isMainConversation || !this.agentMainConversations.has(targetAgentId)) {
this.agentMainConversations.set(targetAgentId, conversationId);
}
if (persist) {
upsertConversationRecord({
id: conversationId,
agentId: targetAgentId,
createdAt,
profileId,
});
}
// Internally consume agent output (AgentEvent stream + error Messages)
void this.consumeAgent(agent);
this.heartbeatRunner?.updateConfig();
console.log(`Agent created: ${agent.sessionId}`);
console.log(`[Hub] Conversation created: ${conversationId} (agent: ${targetAgentId})`);
return agent;
}
@ -426,7 +695,7 @@ export class Hub {
private clearPendingAssistantStarts(agentId: string): void {
for (const [streamId, pending] of this.pendingAssistantStarts) {
if (pending.agentId === agentId) {
if (pending.agentId === agentId || pending.conversationId === agentId) {
this.pendingAssistantStarts.delete(streamId);
}
}
@ -434,28 +703,31 @@ export class Hub {
/** Internally read agent output and send via Gateway */
private async consumeAgent(agent: AsyncAgent): Promise<void> {
const conversationId = agent.sessionId;
const agentId = this.conversationAgents.get(conversationId) ?? conversationId;
for await (const item of agent.read()) {
const targetDeviceId = this.agentSenders.get(agent.sessionId);
const targetDeviceId = this.agentSenders.get(conversationId);
if (!targetDeviceId) continue;
if ("content" in item) {
// Legacy Message (error fallback)
console.log(`[${agent.sessionId}] ${item.content}`);
console.log(`[${conversationId}] ${item.content}`);
this.client.send(targetDeviceId, "message", {
agentId: agent.sessionId,
agentId,
conversationId,
content: item.content,
});
} else {
const suppressForAgent = this.suppressedStreamAgents.has(agent.sessionId);
const suppressForAgent = this.suppressedStreamAgents.has(conversationId);
// Suppress all user-visible stream events during silent heartbeat runs.
if (suppressForAgent) {
if (item.type === "message_start") {
this.beginStream(agent.sessionId, item);
this.beginStream(conversationId, item);
} else if (item.type === "message_end") {
const streamId = this.getActiveStreamId(agent.sessionId, item);
const streamId = this.getActiveStreamId(conversationId, item);
this.pendingAssistantStarts.delete(streamId);
this.endStream(agent.sessionId);
this.endStream(conversationId);
}
continue;
}
@ -465,8 +737,9 @@ export class Hub {
item.type === "compaction_start" || item.type === "compaction_end" || item.type === "agent_error";
if (isPassthroughEvent) {
this.client.send(targetDeviceId, StreamAction, {
streamId: `system:${agent.sessionId}`,
agentId: agent.sessionId,
streamId: `system:${conversationId}`,
agentId,
conversationId,
event: item,
});
continue;
@ -489,17 +762,17 @@ export class Hub {
// This lets us suppress pure HEARTBEAT_OK acknowledgements end-to-end.
if (isAssistantMessageEvent && isAssistantMessage) {
if (item.type === "message_start") {
const streamId = this.beginStream(agent.sessionId, item);
this.pendingAssistantStarts.set(streamId, { agentId: agent.sessionId, event: item });
const streamId = this.beginStream(conversationId, item);
this.pendingAssistantStarts.set(streamId, { agentId, conversationId, event: item });
continue;
}
const streamId = this.getActiveStreamId(agent.sessionId, item);
const streamId = this.getActiveStreamId(conversationId, item);
const isHeartbeatAck = isHeartbeatAckEvent(item);
if (isHeartbeatAck) {
if (item.type === "message_end") {
this.pendingAssistantStarts.delete(streamId);
this.endStream(agent.sessionId);
this.endStream(conversationId);
}
continue;
}
@ -508,7 +781,8 @@ export class Hub {
if (pendingStart) {
this.client.send(targetDeviceId, StreamAction, {
streamId,
agentId: agent.sessionId,
agentId: pendingStart.agentId,
conversationId: pendingStart.conversationId,
event: pendingStart.event,
});
this.pendingAssistantStarts.delete(streamId);
@ -516,19 +790,21 @@ export class Hub {
this.client.send(targetDeviceId, StreamAction, {
streamId,
agentId: agent.sessionId,
agentId,
conversationId,
event: item,
});
if (item.type === "message_end") {
this.endStream(agent.sessionId);
this.endStream(conversationId);
}
continue;
}
const streamId = this.getActiveStreamId(agent.sessionId, item);
const streamId = this.getActiveStreamId(conversationId, item);
this.client.send(targetDeviceId, StreamAction, {
streamId,
agentId: agent.sessionId,
agentId,
conversationId,
event: item,
});
}
@ -540,6 +816,12 @@ export class Hub {
const { requestId, method } = request;
try {
const result = await this.rpc.dispatch(method, request.params, from);
if (method === "createConversation") {
const createdConversationId = (result as { id?: unknown }).id;
if (typeof createdConversationId === "string" && createdConversationId) {
this.deviceStore.allowConversation(from, createdConversationId);
}
}
this.client.send<ResponseSuccessPayload>(from, ResponseAction, {
requestId,
ok: true,
@ -582,7 +864,7 @@ export class Hub {
* Create an exec approval callback for an agent.
* This wires the safety evaluation + Hub approval manager together.
*/
private createExecApprovalCallback(sessionId: string, profileId: string): ExecApprovalCallback {
private createExecApprovalCallback(conversationId: string, agentId: string, profileId: string): ExecApprovalCallback {
return async (command: string, cwd: string | undefined): Promise<ApprovalResult> => {
// Load exec approval config from profile
let config: ExecApprovalConfig = {};
@ -636,7 +918,8 @@ export class Hub {
// Request approval via Hub → Gateway → Client
const result = await this.approvalManager.requestApproval({
agentId: sessionId,
agentId,
conversationId,
command,
...(cwd !== undefined ? { cwd } : {}),
riskLevel: evaluation.riskLevel,
@ -688,6 +971,7 @@ export class Hub {
type,
caption,
filename: basename(filePath),
conversationId: sessionId,
});
console.log(`[Hub] Sent file via gateway: ${basename(filePath)}${deviceId}`);
return true;
@ -702,13 +986,57 @@ export class Hub {
}
getAgent(id: string): AsyncAgent | undefined {
return this.agents.get(id);
const normalizedId = this.normalizeId(id);
if (!normalizedId) return undefined;
const directConversation = this.agents.get(normalizedId);
if (directConversation && !directConversation.closed) {
return directConversation;
}
const mainConversationId = this.resolveAgentMainConversationId(normalizedId);
if (!mainConversationId) return undefined;
const mainConversation = this.agents.get(mainConversationId);
if (!mainConversation || mainConversation.closed) return undefined;
return mainConversation;
}
getConversation(id: string): AsyncAgent | undefined {
const normalizedId = this.normalizeId(id);
if (!normalizedId) return undefined;
const conversation = this.agents.get(normalizedId);
if (!conversation || conversation.closed) return undefined;
return conversation;
}
getConversationAgentId(conversationId: string): string | undefined {
const normalizedConversationId = this.normalizeId(conversationId);
if (!normalizedConversationId) return undefined;
return this.conversationAgents.get(normalizedConversationId);
}
getAgentMainConversationId(agentId: string): string | undefined {
const normalizedAgentId = this.normalizeId(agentId);
if (!normalizedAgentId) return undefined;
return this.resolveAgentMainConversationId(normalizedAgentId);
}
listAgents(): string[] {
const activeAgentIds = new Set<string>();
for (const [conversationId, runtime] of this.agents.entries()) {
if (runtime.closed) continue;
const agentId = this.conversationAgents.get(conversationId);
if (agentId) {
activeAgentIds.add(agentId);
}
}
return Array.from(activeAgentIds.values());
}
listConversations(): string[] {
return Array.from(this.agents.entries())
.filter(([, a]) => !a.closed)
.map(([id]) => id);
.filter(([conversationId, runtime]) => !runtime.closed && this.conversationAgents.has(conversationId))
.map(([conversationId]) => conversationId);
}
/** Subscribe heartbeat state updates. Returns unsubscribe callback. */
@ -763,24 +1091,61 @@ export class Hub {
/** Enqueue a system event for a specific agent or the default agent. */
enqueueSystemEvent(text: string, opts?: { agentId?: string }): void {
const agentId = opts?.agentId ?? this.listAgents()[0];
if (!agentId) return;
enqueueSystemEvent(text, { sessionKey: agentId });
const requestedAgentId = this.normalizeId(opts?.agentId);
const conversationId = requestedAgentId
? this.resolveAgentMainConversationId(requestedAgentId)
: this.listConversations()[0];
if (!conversationId) return;
enqueueSystemEvent(text, { sessionKey: conversationId });
}
closeAgent(id: string): boolean {
const agent = this.agents.get(id);
if (!agent) return false;
agent.close();
this.approvalManager.cancelPending(id);
this.agents.delete(id);
this.agentSenders.delete(id);
this.agentStreamIds.delete(id);
this.agentStreamCounters.delete(id);
this.clearPendingAssistantStarts(id);
this.suppressedStreamAgents.delete(id);
this.localApprovalHandlers.delete(id);
removeAgentRecord(id);
const normalizedId = this.normalizeId(id);
if (!normalizedId) return false;
const resolvedAgentId = this.agentMainConversations.has(normalizedId)
? normalizedId
: this.conversationAgents.get(normalizedId) ?? normalizedId;
const conversationIds = this.listConversationIdsForAgent(resolvedAgentId);
if (conversationIds.length === 0) {
return this.closeConversation(normalizedId);
}
let closedAny = false;
for (const conversationId of conversationIds) {
const closed = this.closeConversationRuntime(conversationId, { persist: false });
closedAny = closedAny || closed.ok;
}
if (!closedAny) return false;
this.agentMainConversations.delete(resolvedAgentId);
this.agentProfiles.delete(resolvedAgentId);
removeAgentRecordById(resolvedAgentId);
this.heartbeatRunner?.updateConfig();
return closedAny;
}
closeConversation(id: string): boolean {
const normalizedId = this.normalizeId(id);
if (!normalizedId) return false;
const conversationId = this.agents.has(normalizedId)
? normalizedId
: this.resolveAgentMainConversationId(normalizedId);
if (!conversationId) return false;
const { ok, agentId } = this.closeConversationRuntime(conversationId);
if (!ok || !agentId) return false;
const currentMainConversationId = this.agentMainConversations.get(agentId);
if (currentMainConversationId === conversationId) {
this.agentMainConversations.delete(agentId);
const replacementConversationId = this.listConversationIdsForAgent(agentId)[0];
if (replacementConversationId) {
this.agentMainConversations.set(agentId, replacementConversationId);
}
}
this.clearAgentIfNoConversation(agentId);
this.heartbeatRunner?.updateConfig();
return true;
}
@ -797,16 +1162,19 @@ export class Hub {
this.heartbeatUnsubscribe = null;
this.heartbeatListeners.clear();
for (const [id, agent] of this.agents) {
for (const [conversationId, agent] of this.agents) {
agent.close();
this.agents.delete(id);
this.agentSenders.delete(id);
this.agentStreamIds.delete(id);
this.agentStreamCounters.delete(id);
this.clearPendingAssistantStarts(id);
this.suppressedStreamAgents.delete(id);
this.localApprovalHandlers.delete(id);
this.agents.delete(conversationId);
this.conversationAgents.delete(conversationId);
this.agentSenders.delete(conversationId);
this.agentStreamIds.delete(conversationId);
this.agentStreamCounters.delete(conversationId);
this.clearPendingAssistantStarts(conversationId);
this.suppressedStreamAgents.delete(conversationId);
this.localApprovalHandlers.delete(conversationId);
}
this.agentMainConversations.clear();
this.agentProfiles.clear();
this.client.disconnect();
console.log("Hub shut down");
}

View file

@ -1,13 +0,0 @@
import type { RpcHandler } from "../dispatcher.js";
interface HubLike {
createAgent(id?: string): { sessionId: string };
}
export function createCreateAgentHandler(hub: HubLike): RpcHandler {
return (params: unknown) => {
const { id } = (params ?? {}) as { id?: string };
const agent = hub.createAgent(id);
return { id: agent.sessionId };
};
}

View file

@ -0,0 +1,24 @@
import { describe, it, expect, vi } from "vitest";
import { createCreateConversationHandler } from "./create-conversation.js";
describe("createCreateConversationHandler", () => {
it("creates conversation with explicit id and agent id", () => {
const createConversation = vi.fn(() => ({ sessionId: "conv-1" }));
const handler = createCreateConversationHandler({ createConversation });
const result = handler({ id: "custom-id", agentId: "agent-1" }, "device-1") as { id: string };
expect(createConversation).toHaveBeenCalledWith("custom-id", { agentId: "agent-1" });
expect(result).toEqual({ id: "conv-1" });
});
it("creates conversation without id when params are missing", () => {
const createConversation = vi.fn(() => ({ sessionId: "conv-2" }));
const handler = createCreateConversationHandler({ createConversation });
const result = handler(undefined, "device-1") as { id: string };
expect(createConversation).toHaveBeenCalledWith(undefined, { agentId: undefined });
expect(result).toEqual({ id: "conv-2" });
});
});

View file

@ -0,0 +1,13 @@
import type { RpcHandler } from "../dispatcher.js";
interface HubLike {
createConversation(id?: string, options?: { agentId?: string }): { sessionId: string };
}
export function createCreateConversationHandler(hub: HubLike): RpcHandler {
return (params: unknown) => {
const { id, agentId } = (params ?? {}) as { id?: string; agentId?: string };
const conversation = hub.createConversation(id, { agentId });
return { id: conversation.sessionId };
};
}

View file

@ -0,0 +1,33 @@
import { describe, it, expect, vi } from "vitest";
import { RpcError } from "../dispatcher.js";
import { createDeleteConversationHandler } from "./delete-conversation.js";
describe("createDeleteConversationHandler", () => {
it("throws INVALID_PARAMS when params are not an object", () => {
const closeConversation = vi.fn();
const handler = createDeleteConversationHandler({ closeConversation });
expect(() => handler(undefined, "device-1")).toThrowError(RpcError);
expect(() => handler(undefined, "device-1")).toThrowError("params must be an object");
expect(closeConversation).not.toHaveBeenCalled();
});
it("throws INVALID_PARAMS when id is missing", () => {
const closeConversation = vi.fn();
const handler = createDeleteConversationHandler({ closeConversation });
expect(() => handler({}, "device-1")).toThrowError(RpcError);
expect(() => handler({}, "device-1")).toThrowError("Missing required param: id");
expect(closeConversation).not.toHaveBeenCalled();
});
it("closes conversation when id is provided", () => {
const closeConversation = vi.fn(() => true);
const handler = createDeleteConversationHandler({ closeConversation });
const result = handler({ id: "conv-1" }, "device-1") as { ok: boolean };
expect(closeConversation).toHaveBeenCalledWith("conv-1");
expect(result).toEqual({ ok: true });
});
});

View file

@ -1,10 +1,10 @@
import { RpcError, type RpcHandler } from "../dispatcher.js";
interface HubLike {
closeAgent(id: string): boolean;
closeConversation(id: string): boolean;
}
export function createDeleteAgentHandler(hub: HubLike): RpcHandler {
export function createDeleteConversationHandler(hub: HubLike): RpcHandler {
return (params: unknown) => {
if (!params || typeof params !== "object") {
throw new RpcError("INVALID_PARAMS", "params must be an object");
@ -13,7 +13,7 @@ export function createDeleteAgentHandler(hub: HubLike): RpcHandler {
if (!id) {
throw new RpcError("INVALID_PARAMS", "Missing required param: id");
}
const ok = hub.closeAgent(id);
const ok = hub.closeConversation(id);
return { ok };
};
}

View file

@ -8,27 +8,54 @@ const DEFAULT_LIMIT = 200;
interface GetAgentMessagesParams {
agentId: string;
conversationId: string;
offset?: number;
limit?: number;
}
export function createGetAgentMessagesHandler(): RpcHandler {
interface ResolvedConversation {
conversationId: string;
storageAgentId?: string;
}
type ConversationResolver = (agentId: string, conversationId: string) => ResolvedConversation | null;
export function createGetAgentMessagesHandler(resolveConversationId?: ConversationResolver): RpcHandler {
return (params: unknown) => {
if (!params || typeof params !== "object") {
throw new RpcError("INVALID_PARAMS", "params must be an object");
}
const { agentId, limit = DEFAULT_LIMIT } = params as GetAgentMessagesParams;
const { agentId, conversationId, limit = DEFAULT_LIMIT } = params as GetAgentMessagesParams;
let { offset } = params as GetAgentMessagesParams;
if (!agentId) {
throw new RpcError("INVALID_PARAMS", "Missing required param: agentId");
}
const normalizedConversationId = (conversationId ?? "").trim();
if (!normalizedConversationId) {
throw new RpcError("INVALID_PARAMS", "Missing required param: conversationId");
}
const resolved = resolveConversationId
? resolveConversationId(agentId, conversationId)
: { conversationId: normalizedConversationId };
const sessionPath = resolveSessionPath(agentId);
if (!existsSync(sessionPath)) {
throw new RpcError("AGENT_NOT_FOUND", `No session found for agent: ${agentId}`);
const resolvedConversationId = resolved?.conversationId?.trim() ?? "";
if (!resolvedConversationId) {
throw new RpcError("INVALID_PARAMS", "Unable to resolve conversationId");
}
const session = new SessionManager({ sessionId: agentId });
const storageOptions = resolved?.storageAgentId
? { agentId: resolved.storageAgentId }
: undefined;
const sessionPath = resolveSessionPath(resolvedConversationId, storageOptions);
if (!existsSync(sessionPath)) {
throw new RpcError("AGENT_NOT_FOUND", `No session found for conversation: ${resolvedConversationId}`);
}
const session = new SessionManager({
sessionId: resolvedConversationId,
...(storageOptions ?? {}),
});
const allMessages = session.loadMessagesForDisplay();
const total = allMessages.length;
const contextWindowTokens = session.getMeta()?.contextWindowTokens ?? session.getContextWindowTokens();
@ -40,6 +67,6 @@ export function createGetAgentMessagesHandler(): RpcHandler {
const sliced = allMessages.slice(offset, offset + limit);
return { messages: sliced, total, offset, limit, contextWindowTokens };
return { messages: sliced, total, offset, limit, conversationId: resolvedConversationId, contextWindowTokens };
};
}

View file

@ -4,7 +4,7 @@ interface HubLike {
hubId: string;
url: string;
connectionState: string;
listAgents(): string[];
listConversations(): string[];
}
export function createGetHubInfoHandler(hub: HubLike): RpcHandler {
@ -12,6 +12,6 @@ export function createGetHubInfoHandler(hub: HubLike): RpcHandler {
hubId: hub.hubId,
url: hub.url,
connectionState: hub.connectionState,
agentCount: hub.listAgents().length,
agentCount: hub.listConversations().length,
});
}

View file

@ -1,16 +0,0 @@
import type { RpcHandler } from "../dispatcher.js";
interface HubLike {
listAgents(): string[];
getAgent(id: string): { closed: boolean } | undefined;
}
export function createListAgentsHandler(hub: HubLike): RpcHandler {
return () => {
const agents = hub.listAgents().map((id) => {
const agent = hub.getAgent(id);
return { id, closed: agent?.closed ?? true };
});
return { agents };
};
}

View file

@ -0,0 +1,35 @@
import { describe, it, expect, vi } from "vitest";
import { createListConversationsHandler } from "./list-conversations.js";
describe("createListConversationsHandler", () => {
it("lists conversations with closed status", () => {
const listConversations = vi.fn(() => ["conv-1", "conv-2"]);
const getConversation = vi.fn((id: string) => (id === "conv-1" ? { closed: false } : { closed: true }));
const handler = createListConversationsHandler({ listConversations, getConversation });
const result = handler(undefined, "device-1") as {
conversations: Array<{ id: string; closed: boolean }>;
};
expect(result).toEqual({
conversations: [
{ id: "conv-1", closed: false },
{ id: "conv-2", closed: true },
],
});
});
it("defaults closed=true when conversation is missing", () => {
const listConversations = vi.fn(() => ["conv-1"]);
const getConversation = vi.fn(() => undefined);
const handler = createListConversationsHandler({ listConversations, getConversation });
const result = handler(undefined, "device-1") as {
conversations: Array<{ id: string; closed: boolean }>;
};
expect(result).toEqual({
conversations: [{ id: "conv-1", closed: true }],
});
});
});

View file

@ -0,0 +1,16 @@
import type { RpcHandler } from "../dispatcher.js";
interface HubLike {
listConversations(): string[];
getConversation(id: string): { closed: boolean } | undefined;
}
export function createListConversationsHandler(hub: HubLike): RpcHandler {
return () => {
const conversations = hub.listConversations().map((id) => {
const conversation = hub.getConversation(id);
return { id, closed: conversation?.closed ?? true };
});
return { conversations };
};
}

View file

@ -0,0 +1,102 @@
import { describe, expect, it, vi } from "vitest";
import { createVerifyHandler } from "./verify.js";
import { RpcError } from "../dispatcher.js";
import type { DeviceStore } from "../../device-store.js";
function createDeviceStoreStub() {
return {
isAllowed: vi.fn(),
consumeToken: vi.fn(),
allowDevice: vi.fn(),
} as unknown as DeviceStore;
}
describe("createVerifyHandler", () => {
it("returns existing authorized conversation scope without consuming token", async () => {
const deviceStore = createDeviceStoreStub();
const storeApi = deviceStore as unknown as {
isAllowed: ReturnType<typeof vi.fn>;
consumeToken: ReturnType<typeof vi.fn>;
allowDevice: ReturnType<typeof vi.fn>;
};
storeApi.isAllowed.mockReturnValue({
agentId: "agent-1",
conversationIds: ["conv-1"],
});
const onConfirmDevice = vi.fn(async () => true);
const handler = createVerifyHandler({
hubId: "hub-1",
deviceStore,
resolveMainConversationId: () => "conv-1",
onConfirmDevice,
});
const result = await handler({}, "dev-1");
expect(result).toEqual({
hubId: "hub-1",
agentId: "agent-1",
conversationId: "conv-1",
isNewDevice: false,
});
expect(storeApi.consumeToken).not.toHaveBeenCalled();
expect(onConfirmDevice).not.toHaveBeenCalled();
});
it("consumes token, confirms device, and stores conversation scope", async () => {
const deviceStore = createDeviceStoreStub();
const storeApi = deviceStore as unknown as {
isAllowed: ReturnType<typeof vi.fn>;
consumeToken: ReturnType<typeof vi.fn>;
allowDevice: ReturnType<typeof vi.fn>;
};
storeApi.isAllowed.mockReturnValue(null);
storeApi.consumeToken.mockReturnValue({
agentId: "agent-2",
conversationId: "conv-2",
});
const onConfirmDevice = vi.fn(async () => true);
const handler = createVerifyHandler({
hubId: "hub-2",
deviceStore,
resolveMainConversationId: () => "conv-2",
onConfirmDevice,
});
const result = await handler({ token: "token-2" }, "dev-2");
expect(result).toEqual({
hubId: "hub-2",
agentId: "agent-2",
conversationId: "conv-2",
isNewDevice: true,
});
expect(onConfirmDevice).toHaveBeenCalledWith("dev-2", "agent-2", "conv-2", undefined);
expect(storeApi.allowDevice).toHaveBeenCalledWith("dev-2", "agent-2", "conv-2", undefined);
});
it("throws REJECTED when user denies device confirmation", async () => {
const deviceStore = createDeviceStoreStub();
const storeApi = deviceStore as unknown as {
isAllowed: ReturnType<typeof vi.fn>;
consumeToken: ReturnType<typeof vi.fn>;
allowDevice: ReturnType<typeof vi.fn>;
};
storeApi.isAllowed.mockReturnValue(null);
storeApi.consumeToken.mockReturnValue({
agentId: "agent-3",
conversationId: "conv-3",
});
const handler = createVerifyHandler({
hubId: "hub-3",
deviceStore,
onConfirmDevice: async () => false,
});
await expect(handler({ token: "token-3" }, "dev-3")).rejects.toMatchObject({
code: "REJECTED",
} satisfies Partial<RpcError>);
expect(storeApi.allowDevice).not.toHaveBeenCalled();
});
});

View file

@ -5,8 +5,14 @@ import type { DeviceStore, DeviceMeta } from "../../device-store.js";
interface VerifyContext {
hubId: string;
deviceStore: DeviceStore;
resolveMainConversationId?: (agentId: string) => string | undefined;
/** Called for first-time connections. Returns true if user approves, false if rejected. */
onConfirmDevice: (deviceId: string, agentId: string, meta?: DeviceMeta) => Promise<boolean>;
onConfirmDevice: (
deviceId: string,
agentId: string,
conversationId: string,
meta?: DeviceMeta,
) => Promise<boolean>;
}
interface VerifyParams {
@ -21,7 +27,19 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler {
// 1. Already in whitelist → pass through (reconnection, no confirmation needed)
const allowed = ctx.deviceStore.isAllowed(from);
if (allowed) {
return { hubId: ctx.hubId, agentId: allowed.agentId, isNewDevice: false };
const preferredConversationId = allowed.conversationIds[0];
const mainConversationId = ctx.resolveMainConversationId?.(allowed.agentId)
?? preferredConversationId
?? allowed.agentId;
const conversationId = allowed.conversationIds.includes(mainConversationId)
? mainConversationId
: preferredConversationId ?? mainConversationId;
return {
hubId: ctx.hubId,
agentId: allowed.agentId,
conversationId,
isNewDevice: false,
};
}
// 2. Validate token
@ -35,13 +53,19 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler {
}
// 3. Token valid → await Desktop user confirmation
const confirmed = await ctx.onConfirmDevice(from, result.agentId, meta);
const confirmed = await ctx.onConfirmDevice(from, result.agentId, result.conversationId, meta);
if (!confirmed) {
throw new RpcError("REJECTED", "Connection rejected by user");
}
// 4. User confirmed → add to whitelist (with device metadata)
ctx.deviceStore.allowDevice(from, result.agentId, meta);
return { hubId: ctx.hubId, agentId: result.agentId, isNewDevice: true };
ctx.deviceStore.allowDevice(from, result.agentId, result.conversationId, meta);
const mainConversationId = ctx.resolveMainConversationId?.(result.agentId) ?? result.conversationId;
return {
hubId: ctx.hubId,
agentId: result.agentId,
conversationId: mainConversationId,
isNewDevice: true,
};
};
}

View file

@ -1,9 +1,9 @@
export { RpcDispatcher, RpcError, type RpcHandler } from "./dispatcher.js";
export { createGetAgentMessagesHandler } from "./handlers/get-agent-messages.js";
export { createGetHubInfoHandler } from "./handlers/get-hub-info.js";
export { createListAgentsHandler } from "./handlers/list-agents.js";
export { createCreateAgentHandler } from "./handlers/create-agent.js";
export { createDeleteAgentHandler } from "./handlers/delete-agent.js";
export { createListConversationsHandler } from "./handlers/list-conversations.js";
export { createCreateConversationHandler } from "./handlers/create-conversation.js";
export { createDeleteConversationHandler } from "./handlers/delete-conversation.js";
export { createUpdateGatewayHandler } from "./handlers/update-gateway.js";
export { createGetLastHeartbeatHandler } from "./handlers/get-last-heartbeat.js";
export { createSetHeartbeatsHandler } from "./handlers/set-heartbeats.js";

View file

@ -41,11 +41,11 @@ export {
type GetAgentMessagesParams,
type GetAgentMessagesResult,
type GetHubInfoResult,
type ListAgentsResult,
type CreateAgentParams,
type CreateAgentResult,
type DeleteAgentParams,
type DeleteAgentResult,
type ListConversationsResult,
type CreateConversationParams,
type CreateConversationResult,
type DeleteConversationParams,
type DeleteConversationResult,
type UpdateGatewayParams,
type UpdateGatewayResult,
type VerifyParams,

View file

@ -56,6 +56,7 @@ export interface Message {
role: "user" | "assistant" | "toolResult" | "system";
content: ContentBlock[];
agentId: string;
conversationId: string;
stopReason?: string;
toolCallId?: string;
toolName?: string;
@ -174,8 +175,21 @@ export function useChat() {
const isStreaming = streamingIds.size > 0;
const reset = useCallback(() => {
setMessages([]);
setStreamingIds(new Set());
setPendingApprovals([]);
setError(null);
setHasMore(false);
setContextWindowTokens(undefined);
}, []);
/** Convert raw AgentMessageItem[] → Message[] */
const convertMessages = useCallback((raw: AgentMessageItem[], agentId: string): Message[] => {
const convertMessages = useCallback((
raw: AgentMessageItem[],
agentId: string,
conversationId: string,
): Message[] => {
const toolCallArgsMap = new Map<string, { name: string; args: Record<string, unknown> }>();
for (const m of raw) {
if (m.role === "assistant") {
@ -190,9 +204,23 @@ export function useChat() {
const loaded: Message[] = [];
for (const m of raw) {
if (m.role === "user") {
loaded.push({ id: uuidv7(), role: "user", content: toContentBlocks(m.content), agentId, source: m.source });
loaded.push({
id: uuidv7(),
role: "user",
content: toContentBlocks(m.content),
agentId,
conversationId,
source: m.source,
});
} else if (m.role === "assistant") {
loaded.push({ id: uuidv7(), role: "assistant", content: toContentBlocks(m.content), agentId, stopReason: m.stopReason });
loaded.push({
id: uuidv7(),
role: "assistant",
content: toContentBlocks(m.content),
agentId,
conversationId,
stopReason: m.stopReason,
});
} else if (m.role === "toolResult") {
const callInfo = toolCallArgsMap.get(m.toolCallId);
loaded.push({
@ -200,6 +228,7 @@ export function useChat() {
role: "toolResult",
content: toContentBlocks(m.content),
agentId,
conversationId,
toolCallId: m.toolCallId,
toolName: m.toolName,
toolArgs: callInfo?.args,
@ -215,9 +244,10 @@ export function useChat() {
const setHistory = useCallback((
raw: AgentMessageItem[],
agentId: string,
conversationId: string,
meta?: { total: number; offset: number; contextWindowTokens?: number },
) => {
const loaded = convertMessages(raw, agentId);
const loaded = convertMessages(raw, agentId, conversationId);
setMessages(loaded);
if (meta) {
setHasMore(meta.offset > 0);
@ -231,9 +261,10 @@ export function useChat() {
const prependHistory = useCallback((
raw: AgentMessageItem[],
agentId: string,
conversationId: string,
meta: { total: number; offset: number; contextWindowTokens?: number },
) => {
const older = convertMessages(raw, agentId);
const older = convertMessages(raw, agentId, conversationId);
setMessages((prev) => [...older, ...prev]);
setHasMore(meta.offset > 0);
if (meta.contextWindowTokens !== undefined) {
@ -242,16 +273,29 @@ export function useChat() {
}, [convertMessages]);
/** Add a user message */
const addUserMessage = useCallback((text: string, agentId: string, source?: MessageSource) => {
const addUserMessage = useCallback((
text: string,
agentId: string,
conversationId: string,
source?: MessageSource,
) => {
setMessages((prev) => [
...prev,
{ id: uuidv7(), role: "user", content: [{ type: "text", text }], agentId, source },
{
id: uuidv7(),
role: "user",
content: [{ type: "text", text }],
agentId,
conversationId,
source,
},
]);
}, []);
/** Process a StreamPayload → update messages + streamingIds */
const handleStream = useCallback((payload: StreamPayload) => {
const { event } = payload;
const conversationId = payload.conversationId;
switch (event.type) {
case "message_start": {
@ -260,6 +304,7 @@ export function useChat() {
role: "assistant",
content: [],
agentId: payload.agentId,
conversationId,
};
const content = extractContent(event);
if (content.length) newMsg.content = content;
@ -285,7 +330,12 @@ export function useChat() {
setMessages((prev) =>
prev.map((m) => {
if (m.id === payload.streamId) return { ...m, content, stopReason };
if (m.role === "toolResult" && m.toolStatus === "running" && m.agentId === payload.agentId) {
if (
m.role === "toolResult"
&& m.toolStatus === "running"
&& m.agentId === payload.agentId
&& m.conversationId === conversationId
) {
return { ...m, toolStatus: "interrupted" as ToolStatus };
}
return m;
@ -306,6 +356,7 @@ export function useChat() {
role: "toolResult",
content: [],
agentId: payload.agentId,
conversationId,
toolCallId: event.toolCallId,
toolName: event.toolName,
toolArgs: event.args as Record<string, unknown> | undefined,
@ -358,6 +409,7 @@ export function useChat() {
role: "system",
content: [],
agentId: payload.agentId,
conversationId,
systemType: "compaction",
compaction: {
removed: ce.removed,
@ -394,6 +446,7 @@ export function useChat() {
error,
// State control (for transport layer to call)
setError,
reset,
setHistory,
prependHistory,
addUserMessage,

View file

@ -17,9 +17,10 @@ interface UseGatewayChatOptions {
client: GatewayClient;
hubId: string;
agentId: string;
conversationId: string;
}
export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions) {
export function useGatewayChat({ client, hubId, agentId, conversationId }: UseGatewayChatOptions) {
const chat = useChat();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
@ -32,10 +33,11 @@ export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions
client
.request<GetAgentMessagesResult>(hubId, "getAgentMessages", {
agentId,
conversationId,
limit: DEFAULT_MESSAGES_LIMIT,
})
.then((result) => {
chat.setHistory(result.messages, agentId, {
chat.setHistory(result.messages, agentId, result.conversationId, {
total: result.total,
offset: result.offset,
contextWindowTokens: result.contextWindowTokens,
@ -44,13 +46,16 @@ export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions
})
.catch(() => {})
.finally(() => setIsLoadingHistory(false));
}, [client, hubId, agentId]);
}, [client, hubId, agentId, conversationId]);
// Subscribe to events
useEffect(() => {
client.onMessage((msg) => {
if (msg.action === StreamAction) {
const payload = msg.payload as StreamPayload;
if (payload.agentId !== agentId || payload.conversationId !== conversationId) {
return;
}
if (payload.event.type === "agent_error") {
const errorMsg = (payload.event as { message?: string }).message ?? "Unknown error";
chat.setError({ code: "AGENT_ERROR", message: errorMsg });
@ -63,7 +68,11 @@ export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions
return;
}
if (msg.action === ExecApprovalRequestAction) {
chat.addApproval(msg.payload as ExecApprovalRequestPayload);
const approval = msg.payload as ExecApprovalRequestPayload;
if (approval.agentId !== agentId || approval.conversationId !== conversationId) {
return;
}
chat.addApproval(approval);
return;
}
if (msg.action === "error") {
@ -72,18 +81,22 @@ export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions
}
});
return () => { client.onMessage(() => {}); };
}, [client]);
}, [client, agentId, conversationId]);
const sendMessage = useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
chat.addUserMessage(trimmed, agentId);
chat.addUserMessage(trimmed, agentId, conversationId, { type: "local" });
chat.setError(null);
client.send(hubId, "message", { agentId, content: trimmed });
client.send(hubId, "message", {
agentId,
conversationId,
content: trimmed,
});
setIsLoading(true);
},
[client, hubId, agentId],
[client, hubId, agentId, conversationId],
);
const loadMore = useCallback(async () => {
@ -96,9 +109,9 @@ export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions
const newOffset = Math.max(0, currentOffset - DEFAULT_MESSAGES_LIMIT);
const limit = currentOffset - newOffset;
const result = await client.request<GetAgentMessagesResult>(
hubId, "getAgentMessages", { agentId, offset: newOffset, limit },
hubId, "getAgentMessages", { agentId, conversationId, offset: newOffset, limit },
);
chat.prependHistory(result.messages, agentId, {
chat.prependHistory(result.messages, agentId, result.conversationId, {
total: result.total,
offset: result.offset,
contextWindowTokens: result.contextWindowTokens,
@ -110,7 +123,7 @@ export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions
isLoadingMoreRef.current = false;
setIsLoadingMore(false);
}
}, [client, hubId, agentId]);
}, [client, hubId, agentId, conversationId]);
const resolveApproval = useCallback(
(approvalId: string, decision: ApprovalDecision) => {

View file

@ -14,6 +14,7 @@ export interface ConnectionIdentity {
gateway: string;
hubId: string;
agentId: string;
conversationId?: string;
}
function loadIdentity(): ConnectionIdentity | null {
@ -21,7 +22,14 @@ function loadIdentity(): ConnectionIdentity | null {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (parsed.gateway && parsed.hubId && parsed.agentId) return parsed;
if (
parsed.gateway
&& parsed.hubId
&& parsed.agentId
&& (parsed.conversationId === undefined || typeof parsed.conversationId === "string")
) {
return parsed;
}
return null;
} catch {
return null;
@ -96,6 +104,7 @@ export function useGatewayConnection(): UseGatewayConnectionReturn {
const [identity, setIdentity] = useState<ConnectionIdentity | null>(null);
const [error, setError] = useState<string | null>(null);
const clientRef = useRef<GatewayClient | null>(null);
const verifiedIdentityRef = useRef<ConnectionIdentity | null>(null);
const disconnectingRef = useRef(false);
const pairingKeyRef = useRef(0);
@ -115,13 +124,25 @@ export function useGatewayConnection(): UseGatewayConnectionReturn {
hubId: id.hubId,
...(token ? { token } : {}),
})
.onVerified((result) => {
if (disconnectingRef.current) return;
verifiedIdentityRef.current = {
gateway: id.gateway,
hubId: result.hubId,
agentId: result.agentId,
conversationId:
id.conversationId
?? result.conversationId,
};
})
.onStateChange((state: ConnectionState) => {
console.log("[GatewayConnection] state:", state);
if (disconnectingRef.current) return;
setConnectionState(state);
if (state === "registered") {
saveIdentity(id);
setIdentity(id);
const resolvedIdentity = verifiedIdentityRef.current ?? id;
saveIdentity(resolvedIdentity);
setIdentity(resolvedIdentity);
setPageState("connected");
}
})
@ -129,6 +150,7 @@ export function useGatewayConnection(): UseGatewayConnectionReturn {
console.log("[GatewayConnection] error:", err.message);
if (disconnectingRef.current) return;
pairingKeyRef.current += 1;
verifiedIdentityRef.current = null;
clearIdentity();
setIdentity(null);
setError(err.message);
@ -149,8 +171,10 @@ export function useGatewayConnection(): UseGatewayConnectionReturn {
if (clientRef.current) {
clientRef.current.disconnect();
clientRef.current = null;
verifiedIdentityRef.current = null;
setTimeout(doConnect, 300);
} else {
verifiedIdentityRef.current = null;
doConnect();
}
},
@ -175,6 +199,7 @@ export function useGatewayConnection(): UseGatewayConnectionReturn {
clearTimeout(timer);
clientRef.current?.disconnect();
clientRef.current = null;
verifiedIdentityRef.current = null;
};
}, []);
@ -183,6 +208,7 @@ export function useGatewayConnection(): UseGatewayConnectionReturn {
pairingKeyRef.current += 1;
clientRef.current?.disconnect();
clientRef.current = null;
verifiedIdentityRef.current = null;
clearIdentity();
setIdentity(null);
setPageState("not-connected");

View file

@ -14,6 +14,8 @@ export interface ExecApprovalRequestPayload {
approvalId: string;
/** Agent that initiated the command */
agentId: string;
/** Conversation ID that initiated the approval request. */
conversationId: string;
/** Shell command requiring approval */
command: string;
/** Working directory */

View file

@ -20,11 +20,11 @@ export {
type GetAgentMessagesParams,
type GetAgentMessagesResult,
type GetHubInfoResult,
type ListAgentsResult,
type CreateAgentParams,
type CreateAgentResult,
type DeleteAgentParams,
type DeleteAgentResult,
type ListConversationsResult,
type CreateConversationParams,
type CreateConversationResult,
type DeleteConversationParams,
type DeleteConversationResult,
type UpdateGatewayParams,
type UpdateGatewayResult,
type DeviceMeta,

View file

@ -66,6 +66,8 @@ export const DEFAULT_MESSAGES_LIMIT = 200;
/** getAgentMessages - request params */
export interface GetAgentMessagesParams {
agentId: string;
/** Conversation ID to read. */
conversationId: string;
offset?: number;
limit?: number;
}
@ -91,6 +93,8 @@ export interface GetAgentMessagesResult {
total: number;
offset: number;
limit: number;
/** Conversation ID used by the server. */
conversationId: string;
/** Context window size (tokens) used by this session */
contextWindowTokens?: number;
}
@ -103,28 +107,29 @@ export interface GetHubInfoResult {
agentCount: number;
}
/** listAgents - no params needed */
export interface ListAgentsResult {
agents: { id: string; closed: boolean }[];
/** listConversations - no params needed */
export interface ListConversationsResult {
conversations: { id: string; closed: boolean }[];
}
/** createAgent - request params */
export interface CreateAgentParams {
/** createConversation - request params (create a conversation, optionally under a specific agent) */
export interface CreateConversationParams {
id?: string;
agentId?: string;
}
/** createAgent - response payload */
export interface CreateAgentResult {
/** createConversation - response payload */
export interface CreateConversationResult {
id: string;
}
/** deleteAgent - request params */
export interface DeleteAgentParams {
/** deleteConversation - request params */
export interface DeleteConversationParams {
id: string;
}
/** deleteAgent - response payload */
export interface DeleteAgentResult {
/** deleteConversation - response payload */
export interface DeleteConversationResult {
ok: boolean;
}
@ -157,4 +162,6 @@ export interface VerifyParams {
export interface VerifyResult {
hubId: string;
agentId: string;
/** Authorized conversation scope for this device. */
conversationId: string;
}

View file

@ -62,6 +62,8 @@ export type AgentErrorEvent = {
export interface StreamPayload {
streamId: string;
agentId: string;
/** Conversation ID of this stream event. */
conversationId: string;
event: AgentEvent | CompactionEvent | AgentErrorEvent;
}

View file

@ -234,7 +234,16 @@ export class GatewayClient {
}
/** Hub 验证成功回调 */
onVerified(callback: (result: { hubId: string; agentId: string; isNewDevice?: boolean }) => void): this {
onVerified(
callback: (
result: {
hubId: string;
agentId: string;
conversationId: string;
isNewDevice?: boolean;
}
) => void,
): this {
this.callbacks.onVerified = callback;
return this;
}
@ -318,12 +327,17 @@ export class GatewayClient {
platform: navigator.platform,
language: navigator.language,
} : undefined;
this.request<{ hubId: string; agentId: string; isNewDevice?: boolean }>(
this.options.hubId,
"verify",
{ token: this.options.token, meta },
this.options.verifyTimeout,
)
this.request<{
hubId: string;
agentId: string;
conversationId: string;
isNewDevice?: boolean;
}>(
this.options.hubId,
"verify",
{ token: this.options.token, meta },
this.options.verifyTimeout,
)
.then((result) => {
// Verify succeeded — now expose "registered" to upper layer
this.callbacks.onVerified?.(result);

View file

@ -110,7 +110,14 @@ export interface GatewayClientCallbacks {
onConnect?: (socketId: string) => void;
onDisconnect?: (reason: string) => void;
onRegistered?: (deviceId: string) => void;
onVerified?: (result: { hubId: string; agentId: string; isNewDevice?: boolean }) => void;
onVerified?: (
result: {
hubId: string;
agentId: string;
conversationId: string;
isNewDevice?: boolean;
}
) => void;
onMessage?: (message: RoutedMessage) => void;
onSendError?: (error: SendErrorResponse) => void;
onPong?: (data: string) => void;

View file

@ -3,6 +3,7 @@ export interface ConnectionInfo {
gateway: string
hubId: string
agentId: string
conversationId?: string
token: string
expires: number
}
@ -15,12 +16,13 @@ function isConnectionInfo(obj: unknown): obj is ConnectionInfo {
typeof o.gateway === "string" &&
typeof o.hubId === "string" &&
typeof o.agentId === "string" &&
(o.conversationId === undefined || typeof o.conversationId === "string") &&
typeof o.token === "string" &&
typeof o.expires === "number"
)
}
// Parse multica://connect?gateway=...&hub=...&agent=...&token=...&exp=... URL format
// Parse multica://connect?gateway=...&hub=...&agent=...&conversation=...&token=...&exp=... URL format
// Uses string prefix + URLSearchParams to avoid cross-engine URL hostname differences
function parseConnectionUrl(input: string): ConnectionInfo | null {
const prefix = "multica://connect?"
@ -30,6 +32,7 @@ function parseConnectionUrl(input: string): ConnectionInfo | null {
const gateway = params.get("gateway")
const hubId = params.get("hub")
const agentId = params.get("agent")
const conversationId = params.get("conversation")
const token = params.get("token")
const exp = params.get("exp")
if (!gateway || !hubId || !agentId || !token || !exp) return null
@ -38,6 +41,7 @@ function parseConnectionUrl(input: string): ConnectionInfo | null {
gateway,
hubId,
agentId,
...(conversationId ? { conversationId } : {}),
token,
expires: Number(exp),
}

View file

@ -44,6 +44,7 @@ export interface Message {
role: "user" | "assistant" | "toolResult" | "system"
content: ContentBlock[]
agentId: string
conversationId?: string
stopReason?: string
toolCallId?: string
toolName?: string

View file

@ -170,6 +170,7 @@ export type StreamEventType =
export interface StreamEvent {
type: StreamEventType
agentId: string
conversationId?: string
streamId?: string
data?: unknown
}
@ -185,6 +186,7 @@ export type ApprovalDecision = 'allow-once' | 'allow-always' | 'deny'
export interface ExecApprovalRequest {
approvalId: string
agentId: string
conversationId?: string
command: string
cwd?: string
riskLevel: RiskLevel

View file

@ -37,6 +37,7 @@ export interface ConnectionIdentity {
gateway: string;
hubId: string;
agentId: string;
conversationId?: string;
}
export interface DevicePairingProps {
@ -150,7 +151,12 @@ export function DevicePairing({
navigator.vibrate?.(50);
setTimeout(() => {
onConnect(
{ gateway: info.gateway, hubId: info.hubId, agentId: info.agentId },
{
gateway: info.gateway,
hubId: info.hubId,
agentId: info.agentId,
...(info.conversationId ? { conversationId: info.conversationId } : {}),
},
info.token,
);
}, 600);
@ -183,7 +189,12 @@ export function DevicePairing({
async (data: string) => {
const info = parseConnectionCode(data);
onConnect(
{ gateway: info.gateway, hubId: info.hubId, agentId: info.agentId },
{
gateway: info.gateway,
hubId: info.hubId,
agentId: info.agentId,
...(info.conversationId ? { conversationId: info.conversationId } : {}),
},
info.token,
);
},