diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index b8656cf2..38990c7f 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -15,6 +15,23 @@ vi.mock('../api/skills', () => ({ vi.mock('../api/mcp', () => ({ mcpApi: { list: vi.fn(async () => ({ servers: [] })), + status: vi.fn(async (name: string) => ({ + server: { + name, + scope: 'user', + transport: 'http', + enabled: true, + status: 'connected', + statusLabel: 'Connected', + configLocation: 'User', + summary: 'https://mcp.example.com/mcp', + canEdit: true, + canRemove: true, + canReconnect: true, + canToggle: true, + config: { type: 'http', url: 'https://mcp.example.com/mcp', headers: {} }, + }, + })), }, })) diff --git a/desktop/src/components/chat/LocalSlashCommandPanel.tsx b/desktop/src/components/chat/LocalSlashCommandPanel.tsx index b0a2e0d0..64c23cac 100644 --- a/desktop/src/components/chat/LocalSlashCommandPanel.tsx +++ b/desktop/src/components/chat/LocalSlashCommandPanel.tsx @@ -118,9 +118,27 @@ function McpPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) { useEffect(() => { let cancelled = false mcpApi.list(cwd) - .then((response) => { + .then(async (response) => { if (cancelled) return - setServers(response.servers.filter((server) => server.scope === 'user' || server.scope === 'local' || server.scope === 'project')) + const visibleServers = response.servers.filter((server) => server.scope === 'user' || server.scope === 'local' || server.scope === 'project') + setServers(visibleServers) + + const statusResults = await Promise.allSettled( + visibleServers.map((server) => mcpApi.status(server.name, cwd)), + ) + if (cancelled) return + + const liveServers = new Map() + for (const result of statusResults) { + if (result.status === 'fulfilled') { + liveServers.set(result.value.server.name, result.value.server) + } + } + if (liveServers.size > 0) { + setServers((current) => + current?.map((server) => liveServers.get(server.name) ?? server) ?? current, + ) + } }) .catch((err) => { if (cancelled) return diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index 44245885..1d661d8a 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -29,8 +29,9 @@ export function AppShell() { // Restore tabs from localStorage await useTabStore.getState().restoreTabs() - const activeId = useTabStore.getState().activeTabId - if (activeId) { + const { activeTabId: activeId, tabs } = useTabStore.getState() + const activeTab = tabs.find((tab) => tab.sessionId === activeId) + if (activeId && activeTab?.type === 'session') { useChatStore.getState().connectToSession(activeId) } if (!cancelled) { diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index c09d9bf3..5130efab 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -274,6 +274,47 @@ describe('chatStore history mapping', () => { providerId: 'provider-1', modelId: 'kimi-k2.6', }) + expect(sendMock.mock.calls.slice(0, 2)).toEqual([ + [ + TEST_SESSION_ID, + { + type: 'set_runtime_config', + providerId: 'provider-1', + modelId: 'kimi-k2.6', + }, + ], + [TEST_SESSION_ID, { type: 'prewarm_session' }], + ]) + }) + + it('prewarms regular desktop sessions when connecting', () => { + useChatStore.getState().connectToSession(TEST_SESSION_ID) + + expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, { + type: 'prewarm_session', + }) + }) + + it('does not prewarm team member sessions', () => { + getMemberBySessionIdMock.mockReturnValue({ + agentId: 'reviewer@test-team', + role: 'reviewer', + status: 'running', + }) + + useChatStore.getState().connectToSession(TEST_SESSION_ID) + + expect(sendMock).not.toHaveBeenCalledWith(TEST_SESSION_ID, { + type: 'prewarm_session', + }) + }) + + it('does not prewarm synthetic app tabs', () => { + useChatStore.getState().connectToSession('__settings__') + + expect(sendMock).not.toHaveBeenCalledWith('__settings__', { + type: 'prewarm_session', + }) }) it('sends explicit runtime overrides over websocket', () => { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 8ad3a130..357466c8 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -228,6 +228,9 @@ export const useChatStore = create((set, get) => ({ if (runtimeSelection) { wsManager.send(sessionId, { type: 'set_runtime_config', ...runtimeSelection }) } + if (!sessionId.startsWith('__') && !useTeamStore.getState().getMemberBySessionId(sessionId)) { + wsManager.send(sessionId, { type: 'prewarm_session' }) + } get().loadHistory(sessionId) sessionsApi.getSlashCommands(sessionId) diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index 27239a05..c0c0247d 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -6,6 +6,7 @@ import type { RuntimeSelection } from './runtime' // ─── Client → Server ────────────────────────────────────────────── export type ClientMessage = + | { type: 'prewarm_session' } | { type: 'user_message'; content: string; attachments?: AttachmentRef[] } | { type: 'permission_response' diff --git a/src/main.tsx b/src/main.tsx index 7fa988bf..554ca95a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2723,6 +2723,26 @@ async function run(): Promise { })); }, configs).catch(err => logForDebugging(`[MCP] ${label} connect error: ${err}`)); }; + const getDesktopMcpStartupTimeoutMs = (): number => { + const raw = process.env.CC_HAHA_DESKTOP_AWAIT_MCP_TIMEOUT_MS; + if (raw === undefined) return 5_000; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 0) return 5_000; + return parsed; + }; + const waitForDesktopMcpStartup = async (promise: Promise, label: string): Promise => { + const timeoutMs = getDesktopMcpStartupTimeoutMs(); + if (timeoutMs === 0) return; + let timer: ReturnType | undefined; + const timedOut = await Promise.race([promise.then(() => false), new Promise(resolve => { + timer = setTimeout(resolve, timeoutMs, true); + })]); + if (timer) clearTimeout(timer); + if (timedOut) { + logForDebugging(`[MCP] ${label} servers not ready after ${timeoutMs}ms — proceeding; background connection continues`); + } + }; + // Await all MCP configs for regular print-mode sessions — they're // often single-turn, so "late-connecting servers visible next turn" // doesn't help. SDK init message and turn-1 tool list should include @@ -2742,10 +2762,13 @@ async function run(): Promise { // fetch was kicked off early (line ~2558) so only residual time blocks // here. --bare skips claude.ai entirely for perf-sensitive scripts. profileCheckpoint('before_connectMcp'); + const regularMcpConnect = connectMcpBatch(regularMcpConfigs, 'regular'); if (sdkUrl) { - void connectMcpBatch(regularMcpConfigs, 'regular'); + if (process.env.CC_HAHA_DESKTOP_AWAIT_MCP === '1') { + await waitForDesktopMcpStartup(regularMcpConnect, 'regular MCP'); + } } else { - await connectMcpBatch(regularMcpConfigs, 'regular'); + await regularMcpConnect; } profileCheckpoint('after_connectMcp'); // Dedup: suppress plugin MCP servers that duplicate a claude.ai diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 5a5bea6a..84046cad 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -249,6 +249,17 @@ describe('ConversationService', () => { expect(args).toContain('--replay-user-messages') }) + test('buildChildEnv asks desktop SDK sessions to wait briefly for MCP tools', async () => { + const service = new ConversationService() as any + const env = (await service.buildChildEnv( + '/tmp', + 'ws://127.0.0.1:3456/sdk/test-session?token=test-token', + )) as Record + + expect(env.CC_HAHA_DESKTOP_AWAIT_MCP).toBe('1') + expect(env.CC_HAHA_DESKTOP_AWAIT_MCP_TIMEOUT_MS).toBe('5000') + }) + test('buildSessionCliArgs forwards the selected runtime model and effort to the CLI process', () => { const service = new ConversationService() as any const args = service.buildSessionCliArgs( diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 2dae22ce..ad1f04da 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -272,6 +272,19 @@ describe('WebSocket Chat Integration', () => { return messages } + + async function waitUntil( + predicate: () => boolean | Promise, + label: string, + timeoutMs = 8000, + ): Promise { + const startedAt = Date.now() + while (Date.now() - startedAt < timeoutMs) { + if (await predicate()) return + await new Promise((resolve) => setTimeout(resolve, 50)) + } + throw new Error(`Timed out waiting for ${label}`) + } const originalCliPath = process.env.CLAUDE_CLI_PATH beforeAll(async () => { @@ -505,6 +518,123 @@ describe('WebSocket Chat Integration', () => { expect(secondTurn.some((m) => m.type === 'error')).toBe(false) }) + it('should prewarm the CLI before the first user turn and reuse that process', async () => { + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir: process.cwd() }), + }) + expect(createRes.status).toBe(201) + const { sessionId } = await createRes.json() as { sessionId: string } + + const originalStartSession = conversationService.startSession.bind(conversationService) + const startCalls: Array<{ sessionId: string }> = [] + + conversationService.startSession = (async function patchedStartSession( + sid: string, + workDir: string, + sdkUrl: string, + options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null }, + ) { + startCalls.push({ sessionId: sid }) + return originalStartSession(sid, workDir, sdkUrl, options) + }) as typeof conversationService.startSession + + const messages: any[] = [] + const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + let connected = false + let awaitingCompletion = false + let preUserMessageCount = 0 + let resolveCompletion: (() => void) | null = null + let rejectCompletion: ((err: Error) => void) | null = null + + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Timed out waiting for prewarm connection for session ${sessionId}`)) + }, 5000) + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data as string) + messages.push(msg) + + if (msg.type === 'connected' && !connected) { + connected = true + clearTimeout(timeout) + ws.send(JSON.stringify({ type: 'prewarm_session' })) + resolve() + return + } + + if (msg.type === 'error') { + const err = new Error(msg.message) + clearTimeout(timeout) + rejectCompletion?.(err) + reject(err) + return + } + + if (awaitingCompletion && msg.type === 'message_complete') { + resolveCompletion?.() + } + } + + ws.onerror = () => { + const err = new Error(`WebSocket error for prewarm session ${sessionId}`) + clearTimeout(timeout) + rejectCompletion?.(err) + reject(err) + } + }) + + await waitUntil( + () => startCalls.length === 1 && conversationService.hasSession(sessionId), + `prewarmed CLI process for ${sessionId}`, + ) + await waitUntil(async () => { + const commandsRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/slash-commands`) + if (!commandsRes.ok) return false + const { commands } = await commandsRes.json() as { commands?: Array<{ name: string }> } + if (!Array.isArray(commands)) return false + return commands.some((command) => command.name === 'help') + }, `prewarmed slash commands for ${sessionId}`) + + preUserMessageCount = messages.length + expect( + messages + .slice(0, preUserMessageCount) + .some((msg) => ['content_start', 'content_delta', 'thinking', 'message_complete'].includes(msg.type)), + ).toBe(false) + + awaitingCompletion = true + const completion = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Timed out waiting for completion after prewarm for session ${sessionId}`)) + }, 10_000) + resolveCompletion = () => { + clearTimeout(timeout) + resolve() + } + rejectCompletion = (err) => { + clearTimeout(timeout) + reject(err) + } + }) + + ws.send(JSON.stringify({ type: 'user_message', content: 'first turn after prewarm' })) + await completion + + expect(startCalls).toHaveLength(1) + expect(messages.some((msg) => msg.type === 'content_delta')).toBe(true) + expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true) + expect(messages.some((msg) => msg.type === 'error')).toBe(false) + } finally { + ws.close() + conversationService.startSession = originalStartSession + conversationService.stopSession(sessionId) + } + }, 20_000) + it('should resume streaming to a reconnected client during an active turn', async () => { await withMockStreamDelay(150, async () => { const sessionId = `chat-reconnect-${crypto.randomUUID()}` diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 2577139f..babf7389 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -232,6 +232,10 @@ export class ConversationService { } } + getRecentSdkMessages(sessionId: string): any[] { + return [...(this.sessions.get(sessionId)?.sdkMessages ?? [])] + } + sendMessage( sessionId: string, content: string, @@ -565,6 +569,12 @@ export class ConversationService { ...(desktopServerUrl ? { CC_HAHA_DESKTOP_SERVER_URL: desktopServerUrl } : {}), + ...(sdkUrl + ? { + CC_HAHA_DESKTOP_AWAIT_MCP: '1', + CC_HAHA_DESKTOP_AWAIT_MCP_TIMEOUT_MS: '5000', + } + : {}), // Tell the CLI entrypoint to skip project .env loading. Provider env // should come from Desktop-managed config or inherited launch env, not // be reintroduced from the repo's .env file. diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts index 525062d3..16278eb9 100644 --- a/src/server/ws/events.ts +++ b/src/server/ws/events.ts @@ -9,6 +9,7 @@ // ============================================================================ export type ClientMessage = + | { type: 'prewarm_session' } | { type: 'user_message'; content: string; attachments?: AttachmentRef[] } | { type: 'permission_response' diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 5d34193a..6b3f9797 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -55,6 +55,11 @@ const runtimeOverrides = new Map() const runtimeTransitionPromises = new Map>() +const sessionStartupPromises = new Map>() +const prewarmPendingSessions = new Set() +const prewarmedSessions = new Set() +const prewarmIdleTimers = new Map>() +const DEFAULT_PREWARM_IDLE_TIMEOUT_MS = 5 * 60_000 export function getSlashCommands(sessionId: string): Array<{ name: string; description: string }> { return sessionSlashCommands.get(sessionId) || [] @@ -98,7 +103,11 @@ export const handleWebSocket = { } activeSessions.set(sessionId, ws) - rebindSessionOutput(sessionId, ws) + if (prewarmedSessions.has(sessionId)) { + bindPrewarmMetadataCapture(sessionId) + } else { + rebindSessionOutput(sessionId, ws) + } const msg: ServerMessage = { type: 'connected', sessionId } ws.send(JSON.stringify(msg)) @@ -139,6 +148,10 @@ export const handleWebSocket = { void handleSetRuntimeConfig(ws, message) break + case 'prewarm_session': + handlePrewarmSession(ws) + break + case 'stop_generation': handleStopGeneration(ws) break @@ -196,10 +209,10 @@ async function handleUserMessage( message: Extract ) { const { sessionId } = ws.data - let workDir = os.homedir() // Clear any stale stop flag from a previous turn sessionStopRequested.delete(sessionId) + clearPrewarmState(sessionId) // Send thinking status sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' }) @@ -222,45 +235,22 @@ async function handleUserMessage( } // 启动 CLI 子进程(如果还没有) - if (!conversationService.hasSession(sessionId)) { - try { - // Resolve the session's actual working directory - try { - const resolved = await sessionService.getSessionWorkDir(sessionId) - if (resolved) workDir = resolved - console.log( - `[WS] handleUserMessage: sessionId=${sessionId}, resolved workDir=${JSON.stringify( - resolved, - )}, will spawn CLI with workDir=${workDir}`, - ) - } catch (resolveErr) { - // fallback to cwd if session file not found - console.warn( - `[WS] handleUserMessage: failed to resolve workDir for ${sessionId}, using fallback=${workDir}: ${ - resolveErr instanceof Error ? resolveErr.message : String(resolveErr) - }`, - ) - } - const runtimeSettings = await getRuntimeSettings(sessionId) - const sdkUrl = - `ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` + - `?token=${encodeURIComponent(crypto.randomUUID())}` - await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings) - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err) - const code = - err instanceof ConversationStartupError ? err.code : 'CLI_START_FAILED' - console.error(`[WS] CLI start failed for ${sessionId}: ${errMsg}`) - sendMessage(ws, { - type: 'error', - message: errMsg, - code, - retryable: - err instanceof ConversationStartupError ? err.retryable : false, - }) - sendMessage(ws, { type: 'status', state: 'idle' }) - return - } + try { + await ensureCliSessionStarted(ws, sessionId, 'user_message') + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err) + const code = + err instanceof ConversationStartupError ? err.code : 'CLI_START_FAILED' + console.error(`[WS] CLI start failed for ${sessionId}: ${errMsg}`) + sendMessage(ws, { + type: 'error', + message: errMsg, + code, + retryable: + err instanceof ConversationStartupError ? err.retryable : false, + }) + sendMessage(ws, { type: 'status', state: 'idle' }) + return } // Track user message for title generation @@ -302,6 +292,29 @@ async function handleUserMessage( userMessageSent = true } +function handlePrewarmSession(ws: ServerWebSocket) { + const { sessionId } = ws.data + if (conversationService.hasSession(sessionId) || sessionStartupPromises.has(sessionId)) { + return + } + + prewarmPendingSessions.add(sessionId) + void ensureCliSessionStarted(ws, sessionId, 'prewarm_session') + .then(() => { + if (!prewarmPendingSessions.delete(sessionId)) return + bindPrewarmMetadataCapture(sessionId) + markPrewarmed(sessionId) + }) + .catch((err) => { + prewarmPendingSessions.delete(sessionId) + console.warn( + `[WS] Prewarm failed for ${sessionId}: ${ + err instanceof Error ? err.message : String(err) + }`, + ) + }) +} + function handlePermissionResponse( ws: ServerWebSocket, message: Extract @@ -575,6 +588,120 @@ function cleanupSessionRuntimeState(sessionId: string) { sessionTitleState.delete(sessionId) runtimeOverrides.delete(sessionId) runtimeTransitionPromises.delete(sessionId) + sessionStartupPromises.delete(sessionId) + clearPrewarmState(sessionId) +} + +function getPrewarmIdleTimeoutMs(): number { + const raw = process.env.CC_HAHA_PREWARM_IDLE_TIMEOUT_MS + if (!raw) return DEFAULT_PREWARM_IDLE_TIMEOUT_MS + const parsed = Number.parseInt(raw, 10) + return Number.isFinite(parsed) && parsed >= 0 + ? parsed + : DEFAULT_PREWARM_IDLE_TIMEOUT_MS +} + +function clearPrewarmState(sessionId: string) { + prewarmPendingSessions.delete(sessionId) + prewarmedSessions.delete(sessionId) + const timer = prewarmIdleTimers.get(sessionId) + if (timer) { + clearTimeout(timer) + prewarmIdleTimers.delete(sessionId) + } +} + +function markPrewarmed(sessionId: string) { + prewarmedSessions.add(sessionId) + const timeoutMs = getPrewarmIdleTimeoutMs() + if (timeoutMs === 0) return + + const existingTimer = prewarmIdleTimers.get(sessionId) + if (existingTimer) clearTimeout(existingTimer) + + const timer = setTimeout(() => { + prewarmIdleTimers.delete(sessionId) + if (!prewarmedSessions.has(sessionId)) return + console.log(`[WS] Prewarmed session ${sessionId} idle for ${timeoutMs}ms, stopping CLI subprocess`) + conversationService.stopSession(sessionId) + prewarmedSessions.delete(sessionId) + }, timeoutMs) + prewarmIdleTimers.set(sessionId, timer) +} + +function cacheSessionInitMetadata(sessionId: string, cliMsg: any) { + if (cliMsg?.type !== 'system' || cliMsg.subtype !== 'init') return + if (cliMsg.slash_commands && Array.isArray(cliMsg.slash_commands)) { + sessionSlashCommands.set(sessionId, cliMsg.slash_commands.map((cmd: any) => ({ + name: typeof cmd === 'string' ? cmd : (cmd.name || cmd.command || ''), + description: typeof cmd === 'string' ? '' : (cmd.description || ''), + }))) + } +} + +function bindPrewarmMetadataCapture(sessionId: string) { + for (const msg of conversationService.getRecentSdkMessages(sessionId)) { + cacheSessionInitMetadata(sessionId, msg) + } + if (!conversationService.hasSession(sessionId)) return + + conversationService.clearOutputCallbacks(sessionId) + conversationService.onOutput(sessionId, (cliMsg) => { + cacheSessionInitMetadata(sessionId, cliMsg) + }) +} + +async function resolveSessionWorkDir(sessionId: string, fallback = os.homedir()): Promise { + let workDir = fallback + try { + const resolved = await sessionService.getSessionWorkDir(sessionId) + if (resolved) workDir = resolved + console.log( + `[WS] resolveSessionWorkDir: sessionId=${sessionId}, resolved workDir=${JSON.stringify( + resolved, + )}, will spawn CLI with workDir=${workDir}`, + ) + } catch (resolveErr) { + console.warn( + `[WS] resolveSessionWorkDir: failed to resolve workDir for ${sessionId}, using fallback=${workDir}: ${ + resolveErr instanceof Error ? resolveErr.message : String(resolveErr) + }`, + ) + } + return workDir +} + +async function ensureCliSessionStarted( + ws: ServerWebSocket, + sessionId: string, + reason: 'user_message' | 'prewarm_session', +): Promise { + const pendingStartup = sessionStartupPromises.get(sessionId) + if (pendingStartup) { + await pendingStartup + return + } + + if (conversationService.hasSession(sessionId)) return + + const startup = (async () => { + const workDir = await resolveSessionWorkDir(sessionId) + const runtimeSettings = await getRuntimeSettings(sessionId) + const sdkUrl = + `ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` + + `?token=${encodeURIComponent(crypto.randomUUID())}` + console.log(`[WS] Starting CLI for ${sessionId} due to ${reason}`) + await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings) + })() + + sessionStartupPromises.set(sessionId, startup) + try { + await startup + } finally { + if (sessionStartupPromises.get(sessionId) === startup) { + sessionStartupPromises.delete(sessionId) + } + } } function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] { @@ -844,12 +971,7 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] { // NOTE: Do NOT send status:idle here — the CLI init fires while // processing the first user message, and sending idle would reset // the frontend's streaming state prematurely. - if (cliMsg.slash_commands && Array.isArray(cliMsg.slash_commands)) { - sessionSlashCommands.set(sessionId, cliMsg.slash_commands.map((cmd: any) => ({ - name: typeof cmd === 'string' ? cmd : (cmd.name || cmd.command || ''), - description: typeof cmd === 'string' ? '' : (cmd.description || ''), - }))) - } + cacheSessionInitMetadata(sessionId, cliMsg) const messages: ServerMessage[] = [ // Send model info as a system notification, not a status change { type: 'system_notification', subtype: 'init', message: `Model: ${cliMsg.model || 'unknown'}`, data: { model: cliMsg.model } },