From 761031c0361915f97a8bf6a95f54070c4ed04e92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 24 Apr 2026 17:11:03 +0800 Subject: [PATCH] fix: Make desktop MCP tools ready before first turn Desktop sessions could show MCP configuration in the UI while the CLI SDK child process still started cold on the first user message. Start eligible desktop chat sessions as soon as their websocket is connected, keep init metadata cached but muted, and wait briefly for MCP startup in SDK print mode so turn one sees the tools. Constraint: Desktop wraps the existing CLI SDK bridge rather than owning MCP startup directly. Rejected: Prewarm every restored tab | synthetic tabs such as settings would start unnecessary CLI subprocesses. Confidence: high Scope-risk: moderate Directive: Do not remove the synthetic-tab guard without validating restored settings and scheduled-task tabs in the browser. Tested: bun test src/server/__tests__/conversations.test.ts src/server/__tests__/conversation-service.test.ts src/server/__tests__/mcp.test.ts Tested: cd desktop && bun run test -- chatStore.test.ts mcpSettings.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: agent-browser E2E new session prewarm plus first message reuse with mock CLI Not-tested: Full desktop Vitest suite still has pre-existing English-vs-Chinese copy assertion failures. --- desktop/src/__tests__/pages.test.tsx | 17 ++ .../chat/LocalSlashCommandPanel.tsx | 22 +- desktop/src/components/layout/AppShell.tsx | 5 +- desktop/src/stores/chatStore.test.ts | 41 ++++ desktop/src/stores/chatStore.ts | 3 + desktop/src/types/chat.ts | 1 + src/main.tsx | 27 ++- .../__tests__/conversation-service.test.ts | 11 + src/server/__tests__/conversations.test.ts | 130 +++++++++++ src/server/services/conversationService.ts | 10 + src/server/ws/events.ts | 1 + src/server/ws/handler.ts | 216 ++++++++++++++---- 12 files changed, 431 insertions(+), 53 deletions(-) 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 } },