cc-haha/src/server/ws/handler.ts
程序员阿江(Relakkes) e4e7026cde fix(provider): diagnose stalled streams
Classify stream watchdog timeouts by stream phase and include non-sensitive diagnostics for stalled provider streams.
Map known watchdog failures to stable desktop WebSocket error codes, and only retry watchdog aborts before content or tool activity starts.

Tested: bun test src/services/api/streamWatchdog.test.ts
Tested: bun test src/server/__tests__/ws-memory-events.test.ts --test-name-pattern "maps watchdog API errors to stable desktop error codes"
Tested: cd desktop && bun test src/stores/chatStore.test.ts --test-name-pattern "persists partial assistant text before an error"
Tested: bun run check:server
Tested: cd desktop && bun run check:desktop
Tested: 5x direct CLI replay, 5x desktop-chain replay, 5x desktop-chain no-entrypoint replay with session 446f8776-538d-46e6-96d6-67080b455b07
2026-07-08 14:13:37 +08:00

2868 lines
95 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* WebSocket connection handler
*
* 管理 WebSocket 连接生命周期,处理消息路由。
* 用户消息通过 CLI 子进程stream-json 模式)处理,
* CLI stdout 消息被转换为 ServerMessage 并转发到 WebSocket。
*/
import type { ServerWebSocket } from 'bun'
import type { ClientMessage, ServerMessage, StreamingFallbackCause, TokenUsage } from './events.js'
import * as os from 'node:os'
import {
ConversationStartupError,
conversationService,
} from '../services/conversationService.js'
import { computerUseApprovalService } from '../services/computerUseApprovalService.js'
import { sessionService } from '../services/sessionService.js'
import { SettingsService } from '../services/settingsService.js'
import { ProviderService } from '../services/providerService.js'
import { isOpenAIOfficialProviderId } from '../services/openaiOfficialProvider.js'
import { diagnosticsService } from '../services/diagnosticsService.js'
import {
buildConversationTitleInput,
deriveTitle,
generateTitle,
resolveTitleLanguagePreference,
saveAiTitle,
type TitleConversationTurn,
} from '../services/titleService.js'
import { parseSlashCommand } from '../../utils/slashCommandParsing.js'
import {
COMMAND_NAME_TAG,
LOCAL_COMMAND_STDERR_TAG,
LOCAL_COMMAND_STDOUT_TAG,
} from '../../constants/xml.js'
import {
getCommandMetadataDisplayText,
shouldHideCommandMetadataContent,
} from '../../utils/commandMetadata.js'
import { shouldCreateWorktreeForSessionLaunch } from '../services/repositoryLaunchService.js'
import { getDisconnectGraceMs } from './disconnectGraceConfig.js'
const settingsService = new SettingsService()
const providerService = new ProviderService()
/**
* Cache slash commands from CLI init messages, keyed by sessionId.
*/
export type SessionSlashCommand = {
name: string
description: string
argumentHint?: string
}
const sessionSlashCommands = new Map<string, SessionSlashCommand[]>()
/**
* Timers for delayed session cleanup after client disconnect.
* If a client reconnects before the timer fires, the timer is cancelled.
*/
const PENDING_PERMISSION_DISCONNECT_CLEANUP_MS = 30 * 60_000
const sessionCleanupTimers = new Map<string, ReturnType<typeof setTimeout>>()
/**
* Per-session removers for the turn-completion watcher (issue #764). When the
* last client disconnects while a turn is still running, we let the turn finish
* in the background instead of killing the CLI, then start the idle grace timer
* once the result arrives. The remover is also cleared on reconnect/cleanup.
*/
const sessionDisconnectWatchers = new Map<string, () => void>()
/**
* Track sessions where user requested stop — suppress the CLI_ERROR that
* follows an interrupt so the frontend doesn't show "处理过程中发生错误".
*/
const sessionStopRequested = new Set<string>()
/**
* Track user message count and title state per session for auto-title generation.
*/
const sessionTitleState = new Map<string, {
userMessageCount: number
hasCustomTitle: boolean
firstUserMessage: string
completedTurns: TitleConversationTurn[]
activeTurn?: TitleConversationTurn & { count: number }
startedGenerationKeys: Set<string>
generationSeq: number
}>()
type RuntimeOverride = {
providerId: string | null
modelId: string
effort?: string
}
type ActiveUserTurnState = {
messageSent: boolean
}
const runtimeOverrides = new Map<string, RuntimeOverride>()
const activeUserTurns = new Map<string, ActiveUserTurnState>()
const deferredRuntimeRestarts = new Map<string, RuntimeOverride>()
const deferredPermissionModes = new Map<string, string>()
const runtimeTransitionPromises = new Map<string, Promise<void>>()
const sessionStartupPromises = new Map<string, Promise<void>>()
const runtimeOverrideVersions = new Map<string, number>()
const sessionStartupRuntimeVersions = new Map<string, number>()
const lastResolvedStartupWorkDirs = new Map<string, string>()
const prewarmPendingSessions = new Set<string>()
const prewarmedSessions = new Set<string>()
const prewarmIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
const DEFAULT_PREWARM_IDLE_TIMEOUT_MS = 5 * 60_000
const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max'])
async function sendRepositoryStartupStatus(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
reason: 'user_message' | 'prewarm_session',
): Promise<void> {
if (reason !== 'user_message') return
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null)
const repository = launchInfo?.repository
if (!repository) return
if (shouldCreateWorktreeForSessionLaunch(launchInfo)) {
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Creating worktree' })
}
}
export function getSlashCommands(sessionId: string): SessionSlashCommand[] {
return sessionSlashCommands.get(sessionId) || []
}
function usageNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
function translateCliUsage(usage: unknown): TokenUsage {
const record = usage && typeof usage === 'object'
? usage as Record<string, unknown>
: {}
const cacheReadTokens = usageNumber(record.cache_read_input_tokens ?? record.cache_read_tokens)
const cacheCreationTokens = usageNumber(record.cache_creation_input_tokens ?? record.cache_creation_tokens)
return {
input_tokens: usageNumber(record.input_tokens),
output_tokens: usageNumber(record.output_tokens),
...(cacheReadTokens > 0 ? { cache_read_tokens: cacheReadTokens } : {}),
...(cacheCreationTokens > 0 ? { cache_creation_tokens: cacheCreationTokens } : {}),
}
}
export type WebSocketData = {
sessionId: string
connectedAt: number
channel: 'client' | 'sdk'
sdkToken: string | null
serverPort: number
serverHost: string
}
// Active WebSocket clients, grouped by session. Desktop, H5, and IM adapters can
// legitimately watch the same running session at the same time.
const activeSessions = new Map<string, Set<ServerWebSocket<WebSocketData>>>()
const clientOutputCallbacks = new Map<
ServerWebSocket<WebSocketData>,
{
sessionId: string
callback: (cliMsg: any) => void
}
>()
export const handleWebSocket = {
open(ws: ServerWebSocket<WebSocketData>) {
const { sessionId, channel, sdkToken } = ws.data
if (channel === 'sdk') {
if (!conversationService.authorizeSdkConnection(sessionId, sdkToken)) {
console.warn(`[WS] Rejected SDK connection for session: ${sessionId}`)
ws.close(1008, 'Invalid SDK token')
return
}
conversationService.attachSdkConnection(sessionId, ws)
console.log(`[WS] SDK connected for session: ${sessionId}`)
return
}
console.log(`[WS] Client connected for session: ${sessionId}`)
// Cancel pending cleanup timer if client reconnects
const pendingTimer = sessionCleanupTimers.get(sessionId)
if (pendingTimer) {
clearTimeout(pendingTimer)
sessionCleanupTimers.delete(sessionId)
}
// Cancel any "let the running turn finish, then clean up" watcher too —
// the session is observed again (issue #764).
cancelSessionDisconnectWatcher(sessionId)
addActiveClient(sessionId, ws)
if (prewarmPendingSessions.has(sessionId) || prewarmedSessions.has(sessionId)) {
bindPrewarmMetadataCapture(sessionId)
} else {
bindClientSessionOutput(sessionId, ws)
}
const msg: ServerMessage = { type: 'connected', sessionId }
ws.send(JSON.stringify(msg))
replayPendingPermissionRequests(ws, sessionId)
},
message(ws: ServerWebSocket<WebSocketData>, rawMessage: string | Buffer) {
if (ws.data.channel === 'sdk') {
const payload = typeof rawMessage === 'string' ? rawMessage : rawMessage.toString()
conversationService.handleSdkPayload(ws.data.sessionId, payload)
return
}
try {
const message = JSON.parse(
typeof rawMessage === 'string' ? rawMessage : rawMessage.toString()
) as ClientMessage
switch (message.type) {
case 'user_message':
handleUserMessage(ws, message).catch((err) => {
void diagnosticsService.recordEvent({
type: 'ws_user_message_failed',
severity: 'error',
sessionId: ws.data.sessionId,
summary: err instanceof Error ? err.message : String(err),
details: err,
})
console.error(`[WS] Unhandled error in handleUserMessage:`, err)
})
break
case 'permission_response':
handlePermissionResponse(ws, message)
break
case 'computer_use_permission_response':
handleComputerUsePermissionResponse(ws, message)
break
case 'set_permission_mode':
void handleSetPermissionMode(ws, message)
break
case 'set_runtime_config':
void handleSetRuntimeConfig(ws, message)
break
case 'prewarm_session':
void handlePrewarmSession(ws)
break
case 'stop_generation':
handleStopGeneration(ws)
break
case 'ping':
ws.send(JSON.stringify({ type: 'pong' } satisfies ServerMessage))
break
default:
sendError(ws, `Unknown message type: ${(message as any).type}`, 'UNKNOWN_TYPE')
}
} catch (error) {
sendError(ws, `Invalid message format: ${error}`, 'PARSE_ERROR')
}
},
close(ws: ServerWebSocket<WebSocketData>, code: number, reason: string) {
const { sessionId, channel } = ws.data
if (channel === 'sdk') {
console.log(`[WS] SDK disconnected from session: ${sessionId} (${code}: ${reason})`)
conversationService.detachSdkConnection(sessionId)
return
}
console.log(`[WS] Client disconnected from session: ${sessionId} (${code}: ${reason})`)
if (!removeActiveClient(sessionId, ws)) {
console.log(`[WS] Ignoring stale client disconnect for session: ${sessionId}`)
return
}
removeClientOutputCallback(ws)
if (hasActiveClients(sessionId)) {
return
}
// No clients left. A turn that is still running must finish in the
// background (issue #764) — never kill it just because a phone locked its
// screen. Defer cleanup until the turn completes, then apply the idle
// grace period. Sessions that are already idle go straight to the timer.
if (isSessionTurnActive(sessionId)) {
console.log(`[WS] Session ${sessionId} still running after disconnect; keeping CLI alive until the turn finishes`)
watchTurnCompletionForCleanup(sessionId)
return
}
scheduleDisconnectCleanup(sessionId)
},
drain(ws: ServerWebSocket<WebSocketData>) {
// Backpressure handling - called when the socket is ready to receive more data
},
}
// ============================================================================
// Message handlers
// ============================================================================
async function handleUserMessage(
ws: ServerWebSocket<WebSocketData>,
message: Extract<ClientMessage, { type: 'user_message' }>
) {
const { sessionId } = ws.data
// Clear any stale stop flag from a previous turn
sessionStopRequested.delete(sessionId)
clearPrewarmState(sessionId)
const desktopSlashCommand = getDesktopSlashCommand(message.content)
if (desktopSlashCommand?.commandName === 'clear' && desktopSlashCommand.args.trim()) {
sendMessage(ws, {
type: 'error',
message: 'The /clear command does not accept arguments.',
code: 'INVALID_SLASH_COMMAND_ARGS',
})
sendMessage(ws, { type: 'status', state: 'idle' })
return
}
if (desktopSlashCommand?.commandName === 'clear') {
await handleDesktopClearCommand(ws)
return
}
// Send thinking status
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' })
const activeTurn: ActiveUserTurnState = { messageSent: false }
activeUserTurns.set(sessionId, activeTurn)
const initialRuntimeTransition = await waitForRuntimeTransitionBeforeUserTurn(ws, sessionId)
if (!initialRuntimeTransition.ok) {
clearActiveUserTurn(sessionId, activeTurn)
return
}
if (initialRuntimeTransition.waited) {
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' })
}
// Track and emit the first placeholder title before CLI startup/streaming.
let titleState = sessionTitleState.get(sessionId)
if (!titleState) {
titleState = {
userMessageCount: 0,
hasCustomTitle: !!(await sessionService.getCustomTitle(sessionId)),
firstUserMessage: '',
completedTurns: [],
startedGenerationKeys: new Set<string>(),
generationSeq: 0,
}
sessionTitleState.set(sessionId, titleState)
}
const titleInput = getTitleInputForUserMessage(message.content, desktopSlashCommand)
let titleTurnNumber: number | null = null
if (titleInput) {
titleState.userMessageCount++
titleTurnNumber = titleState.userMessageCount
titleState.activeTurn = {
count: titleTurnNumber,
userText: titleInput,
assistantText: '',
}
if (titleState.userMessageCount === 1) {
titleState.firstUserMessage = titleInput
}
triggerTitleGeneration(ws, sessionId, 'user-message')
}
// 启动 CLI 子进程(如果还没有)
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: await buildSessionStartupDiagnosticMessage(sessionId, errMsg),
code,
retryable:
err instanceof ConversationStartupError ? err.retryable : false,
})
sendMessage(ws, { type: 'status', state: 'idle' })
clearActiveUserTurn(sessionId, activeTurn)
return
}
const startupRuntimeTransition = await waitForRuntimeTransitionBeforeUserTurn(ws, sessionId)
if (startupRuntimeTransition.ok) {
if (startupRuntimeTransition.waited) {
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' })
}
} else {
clearActiveUserTurn(sessionId, activeTurn)
return
}
// Register the callback before sending the turn so startup errors are not lost.
// Keep output muted until the current user turn is enqueued to avoid forwarding
// any pre-turn SDK chatter as fresh chat history.
let userMessageSent = false
const shouldForwardCurrentTurnLocalCommand =
createCurrentTurnLocalCommandForwarder(desktopSlashCommand)
const removeTitleOutputCallback = titleTurnNumber === null
? null
: bindTitleSessionOutput(ws, sessionId, () => userMessageSent)
bindAllClientSessionOutputs(sessionId, {
shouldForward: (cliMsg) => {
if (userMessageSent || (cliMsg.type === 'result' && cliMsg.is_error)) {
return true
}
return shouldForwardCurrentTurnLocalCommand(cliMsg)
},
})
const removeActiveTurnOutputCallback = bindActiveUserTurnCompletion(ws, sessionId, activeTurn)
const sent = await conversationService.sendMessage(
sessionId,
message.content,
message.attachments
)
if (!sent) {
removeActiveTurnOutputCallback()
clearActiveUserTurn(sessionId, activeTurn)
removeTitleOutputCallback?.()
discardActiveTitleTurn(sessionId, titleTurnNumber)
sendMessage(ws, {
type: 'error',
message: 'CLI process is not running. The session may have ended or the process crashed.',
code: 'CLI_NOT_RUNNING',
})
sendMessage(ws, { type: 'status', state: 'idle' })
return
}
userMessageSent = true
activeTurn.messageSent = true
}
function clearActiveUserTurn(sessionId: string, activeTurn: ActiveUserTurnState): void {
if (activeUserTurns.get(sessionId) === activeTurn) {
activeUserTurns.delete(sessionId)
}
}
function bindActiveUserTurnCompletion(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
activeTurn: ActiveUserTurnState,
): () => void {
const callback = (cliMsg: any) => {
if (!activeTurn.messageSent || cliMsg?.type !== 'result') return
conversationService.removeOutputCallback(sessionId, callback)
clearActiveUserTurn(sessionId, activeTurn)
// Structurally disarm any prewarm idle timer that a concurrent
// prewarm_session/user_message flush may have armed on this session: once a
// turn completes the session is firmly user-owned, so no prewarm reaper
// should survive — regardless of the order in which the two raced.
clearPrewarmState(sessionId)
applyDeferredPermissionModeAfterActiveTurn(ws, sessionId)
applyDeferredRuntimeRestartAfterActiveTurn(ws, sessionId)
}
conversationService.onOutput(sessionId, callback)
return () => conversationService.removeOutputCallback(sessionId, callback)
}
function shouldDeferRuntimeRestartForActiveTurn(sessionId: string): boolean {
return activeUserTurns.get(sessionId)?.messageSent === true
}
function applyDeferredPermissionModeAfterActiveTurn(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
): void {
const deferredMode = deferredPermissionModes.get(sessionId)
if (!deferredMode) return
deferredPermissionModes.delete(sessionId)
void enqueueRuntimeTransition(sessionId, async () => {
if (!conversationService.hasSession(sessionId)) return
await applyPermissionModeToActiveSession(ws, sessionId, deferredMode)
})
}
function applyDeferredRuntimeRestartAfterActiveTurn(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
): void {
const deferred = deferredRuntimeRestarts.get(sessionId)
if (!deferred) return
deferredRuntimeRestarts.delete(sessionId)
void enqueueRuntimeTransition(sessionId, async () => {
const currentOverride = runtimeOverrides.get(sessionId)
if (
!currentOverride ||
currentOverride.providerId !== deferred.providerId ||
currentOverride.modelId !== deferred.modelId ||
currentOverride.effort !== deferred.effort ||
!conversationService.hasSession(sessionId)
) {
return
}
await restartSessionWithRuntimeConfig(ws, sessionId)
})
}
async function handleDesktopClearCommand(
ws: ServerWebSocket<WebSocketData>,
) {
const { sessionId } = ws.data
const workDir = conversationService.getSessionWorkDir(sessionId)
const permissionMode = conversationService.hasSession(sessionId)
? conversationService.getSessionPermissionMode(sessionId)
: undefined
conversationService.stopSession(sessionId)
conversationService.clearOutputCallbacks(sessionId)
sessionSlashCommands.delete(sessionId)
sessionTitleState.delete(sessionId)
cleanupStreamState(sessionId)
try {
await sessionService.clearSessionTranscript(sessionId, workDir || undefined, permissionMode)
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err)
sendMessage(ws, {
type: 'error',
message: errMsg,
code: 'SESSION_CLEAR_FAILED',
})
sendMessage(ws, { type: 'status', state: 'idle' })
return
}
sendMessage(ws, {
type: 'system_notification',
subtype: 'session_cleared',
message: 'Conversation cleared',
})
sendMessage(ws, {
type: 'message_complete',
usage: { input_tokens: 0, output_tokens: 0 },
})
}
async function handlePrewarmSession(ws: ServerWebSocket<WebSocketData>) {
const { sessionId } = ws.data
if (conversationService.hasSession(sessionId) || sessionStartupPromises.has(sessionId)) {
return
}
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null)
// Re-check after async gap: a user_message may have arrived during the await
// and already started (or is starting) the CLI session. If so, skip prewarm
// entirely — the user turn owns this session now, and calling markPrewarmed()
// would arm an idle timer that later kills the active conversation.
if (conversationService.hasSession(sessionId) || sessionStartupPromises.has(sessionId)) {
return
}
if (launchInfo?.repository) {
console.log(`[WS] Skipping prewarm for pending repository launch session ${sessionId}`)
return
}
prewarmPendingSessions.add(sessionId)
void ensureCliSessionStarted(ws, sessionId, 'prewarm_session')
.then(() => {
const stillPending = prewarmPendingSessions.delete(sessionId)
if (!stillPending) return
// Safety: if a user message arrived and claimed this session while we
// were waiting for startup, do NOT arm the prewarm idle timer — the
// session is now owned by the user conversation, not prewarm. Use the
// turn-registered check (not messageSent) so the CLI-startup window is
// covered: in the concurrent race the turn is registered but messageSent
// is still false when this .then runs, which made the old guard dead code.
if (hasPendingOrActiveUserTurn(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<WebSocketData>,
message: Extract<ClientMessage, { type: 'permission_response' }>
) {
const { sessionId } = ws.data
conversationService.respondToPermission(
sessionId,
message.requestId,
message.allowed,
message.rule,
message.updatedInput,
message.denyMessage,
message.permissionUpdates,
)
console.log(`[WS] Permission response for ${message.requestId}: ${message.allowed}`)
}
function handleComputerUsePermissionResponse(
ws: ServerWebSocket<WebSocketData>,
message: Extract<ClientMessage, { type: 'computer_use_permission_response' }>
) {
const { sessionId } = ws.data
const ok = computerUseApprovalService.resolveApproval(
message.requestId,
message.response,
)
if (!ok) {
console.warn(
`[WS] Ignored Computer Use permission response for unknown request ${message.requestId} from ${sessionId}`
)
}
}
async function handleSetPermissionMode(
ws: ServerWebSocket<WebSocketData>,
message: Extract<ClientMessage, { type: 'set_permission_mode' }>
): Promise<void> {
const { sessionId } = ws.data
const pendingStartup = sessionStartupPromises.get(sessionId)
if (pendingStartup) {
await enqueueRuntimeTransition(sessionId, async () => {
await pendingStartup.catch(() => undefined)
if (!conversationService.hasSession(sessionId)) return
await applyPermissionModeToActiveSession(ws, sessionId, message.mode)
})
return
}
if (!conversationService.hasSession(sessionId)) {
if (await persistSessionPermissionMode(sessionId, message.mode)) {
sendMessage(ws, { type: 'permission_mode_changed', mode: message.mode })
}
return
}
await applyPermissionModeToActiveSession(ws, sessionId, message.mode)
}
/**
* 决定一次权限模式切换是否需要重启 CLI 子进程。
*
* 只有"进入 bypassPermissions"才需要重启CLI 必须带 --dangerously-skip-permissions
* 启动,否则运行时的 set_permission_mode → bypassPermissions 会被拒绝,所以重启子进程
* 带上该 flag。
*
* 反过来"从 bypassPermissions 切到更严格的模式"**不要**重启:此时进程已带 flag运行时
* 降级即可。更关键的是——重启会把进程内的 prePlanMode 记忆冲掉:若 bypass→plan 走重启,
* 新 CLI 直接以 plan 启动、prePlanMode 为空ExitPlanMode 只能恢复成 default 而非进入前的
* bypassPermissions。保持进程不变、走 setPermissionMode 做进程内 transitionCLI 才会像 TUI
* 一样栈存 prePlanMode='bypassPermissions',退出 plan 时正确恢复 bypass。
*/
export function shouldRestartForPermissionMode(
currentMode: string,
mode: string,
): boolean {
if (currentMode === mode) return false
return mode === 'bypassPermissions'
}
async function applyPermissionModeToActiveSession(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
mode: string,
): Promise<void> {
const currentMode = conversationService.getSessionPermissionMode(sessionId)
if (shouldDeferRuntimeRestartForActiveTurn(sessionId)) {
deferredPermissionModes.set(sessionId, mode)
return
}
if (currentMode === mode) {
sendMessage(ws, { type: 'permission_mode_changed', mode })
return
}
const needsRestart = shouldRestartForPermissionMode(currentMode, mode)
if (needsRestart) {
void enqueueRuntimeTransition(sessionId, () =>
restartSessionWithPermissionMode(ws, sessionId, mode),
)
return
}
const ok = conversationService.setPermissionMode(sessionId, mode)
if (!ok) {
console.warn(`[WS] Ignored permission mode update for inactive session ${sessionId}`)
return
}
await persistSessionPermissionMode(sessionId, mode)
sendMessage(ws, { type: 'permission_mode_changed', mode })
}
async function handleSetRuntimeConfig(
ws: ServerWebSocket<WebSocketData>,
message: Extract<ClientMessage, { type: 'set_runtime_config' }>
) {
const { sessionId } = ws.data
const modelId = typeof message.modelId === 'string' ? message.modelId.trim() : ''
if (!modelId) {
sendMessage(ws, {
type: 'error',
message: 'Runtime model selection is invalid.',
code: 'RUNTIME_CONFIG_INVALID',
})
return
}
const effortLevel =
typeof message.effortLevel === 'string' ? message.effortLevel.trim() : undefined
if (effortLevel !== undefined && !VALID_EFFORT_LEVELS.has(effortLevel)) {
sendMessage(ws, {
type: 'error',
message: 'Runtime effort selection is invalid.',
code: 'RUNTIME_CONFIG_INVALID',
})
return
}
const nextOverride = {
providerId: message.providerId ?? null,
modelId,
...(effortLevel ? { effort: effortLevel } : {}),
}
const prevOverride = runtimeOverrides.get(sessionId)
if (
prevOverride &&
prevOverride.providerId === nextOverride.providerId &&
prevOverride.modelId === nextOverride.modelId &&
prevOverride.effort === nextOverride.effort
) {
return
}
runtimeOverrides.set(sessionId, nextOverride)
runtimeOverrideVersions.set(
sessionId,
(runtimeOverrideVersions.get(sessionId) ?? 0) + 1,
)
if (shouldDeferRuntimeRestartForActiveTurn(sessionId)) {
deferredRuntimeRestarts.set(sessionId, nextOverride)
await persistSessionRuntimeConfig(sessionId, nextOverride)
return
}
if (conversationService.hasSession(sessionId)) {
await enqueueRuntimeTransition(sessionId, async () => {
await persistSessionRuntimeConfig(sessionId, nextOverride)
await restartSessionWithRuntimeConfig(ws, sessionId)
})
return
}
const pendingStartup = sessionStartupPromises.get(sessionId)
if (pendingStartup) {
const startupRuntimeVersion = sessionStartupRuntimeVersions.get(sessionId) ?? 0
const currentRuntimeVersion = runtimeOverrideVersions.get(sessionId) ?? 0
if (startupRuntimeVersion >= currentRuntimeVersion) {
await persistSessionRuntimeConfig(sessionId, nextOverride)
return
}
await enqueueRuntimeTransition(sessionId, async () => {
await persistSessionRuntimeConfig(sessionId, nextOverride)
await pendingStartup.catch(() => undefined)
const currentOverride = runtimeOverrides.get(sessionId)
if (
currentOverride?.providerId !== nextOverride.providerId ||
currentOverride.modelId !== nextOverride.modelId ||
currentOverride.effort !== nextOverride.effort ||
!conversationService.hasSession(sessionId)
) {
return
}
await restartSessionWithRuntimeConfig(ws, sessionId)
})
return
}
await persistSessionRuntimeConfig(sessionId, nextOverride)
}
async function restartSessionWithPermissionMode(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
mode: string,
): Promise<void> {
try {
const workDir = conversationService.getSessionWorkDir(sessionId)
await persistSessionPermissionMode(sessionId, mode, workDir)
conversationService.stopSession(sessionId)
// Rebuild runtime settings (will pick up the session-scoped mode)
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)
sendMessage(ws, { type: 'permission_mode_changed', mode })
sendMessage(ws, { type: 'status', state: 'idle' })
console.log(`[WS] Restarted CLI for ${sessionId} with permission mode: ${mode}`)
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err)
void diagnosticsService.recordEvent({
type: 'permission_restart_failed',
severity: 'error',
sessionId,
summary: errMsg,
details: { mode, error: err },
})
console.error(`[WS] Failed to restart CLI for ${sessionId}: ${errMsg}`)
sendMessage(ws, {
type: 'error',
message: await buildSessionStartupDiagnosticMessage(
sessionId,
`Failed to restart session with new permission mode: ${errMsg}`,
),
code: 'CLI_RESTART_FAILED',
})
sendMessage(ws, { type: 'status', state: 'idle' })
}
}
async function persistSessionPermissionMode(
sessionId: string,
mode: string,
knownWorkDir?: string | null,
): Promise<boolean> {
const workDir =
knownWorkDir ||
conversationService.getSessionWorkDir(sessionId) ||
await sessionService.getSessionWorkDir(sessionId).catch(() => null)
if (!workDir) return false
await sessionService.appendSessionMetadata(sessionId, {
workDir,
permissionMode: mode,
})
return true
}
async function persistSessionRuntimeConfig(
sessionId: string,
runtime: { providerId: string | null; modelId: string; effort?: string },
): Promise<void> {
const workDir =
conversationService.getSessionWorkDir(sessionId) ||
await sessionService.getSessionWorkDir(sessionId).catch(() => null)
if (!workDir) return
await sessionService.appendSessionMetadata(sessionId, {
workDir,
runtimeProviderId: runtime.providerId,
runtimeModelId: runtime.modelId,
...(runtime.effort ? { effortLevel: runtime.effort } : {}),
})
}
async function restartSessionWithRuntimeConfig(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
): Promise<void> {
try {
const workDir = conversationService.getSessionWorkDir(sessionId)
conversationService.stopSession(sessionId)
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)
sendMessage(ws, { type: 'status', state: 'idle' })
console.log(`[WS] Restarted CLI for ${sessionId} with runtime override`)
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err)
void diagnosticsService.recordEvent({
type: 'runtime_config_restart_failed',
severity: 'error',
sessionId,
summary: errMsg,
details: { runtimeOverride: runtimeOverrides.get(sessionId), error: err },
})
console.error(`[WS] Failed to restart CLI for ${sessionId} after runtime override: ${errMsg}`)
sendMessage(ws, {
type: 'error',
message: await buildSessionStartupDiagnosticMessage(
sessionId,
`Failed to switch provider/model: ${errMsg}`,
),
code: 'CLI_RESTART_FAILED',
})
sendMessage(ws, { type: 'status', state: 'idle' })
}
}
function handleStopGeneration(ws: ServerWebSocket<WebSocketData>) {
const { sessionId } = ws.data
console.log(`[WS] Stop generation requested for session: ${sessionId}`)
sessionStopRequested.add(sessionId)
if (conversationService.hasSession(sessionId)) {
// First try graceful interrupt via SDK control message
conversationService.sendInterrupt(sessionId)
// Force-kill if still running after 3 seconds
setTimeout(() => {
if (conversationService.hasSession(sessionId)) {
console.log(`[WS] Force-killing CLI subprocess for session: ${sessionId}`)
conversationService.stopSession(sessionId)
}
}, 3_000)
}
sendMessage(ws, { type: 'status', state: 'idle' })
}
// ============================================================================
// Title generation
// ============================================================================
type TitleGenerationPhase = 'user-message' | 'turn-complete'
function triggerTitleGeneration(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
phase: TitleGenerationPhase,
completedTurnCount?: number,
): void {
const state = sessionTitleState.get(sessionId)
if (!state || state.hasCustomTitle) return
const count = phase === 'turn-complete'
? completedTurnCount ?? state.userMessageCount
: state.userMessageCount
if (phase === 'user-message') {
if (count !== 1) return
const key = 'placeholder:1'
if (state.startedGenerationKeys.has(key)) return
state.startedGenerationKeys.add(key)
void (async () => {
try {
const text = state.firstUserMessage
const placeholder = deriveTitle(text)
if (placeholder) {
const saved = await saveAiTitle(sessionId, placeholder)
if (!saved) {
state.hasCustomTitle = true
return
}
sendSessionTitleUpdated(ws, sessionId, placeholder)
}
} catch (err) {
console.error(`[Title] Failed to derive title for ${sessionId}:`, err)
}
})()
return
}
// Generate polished titles after assistant output completes on turn 1 and 3.
if (count !== 1 && count !== 3) return
const key = `complete:${count}`
if (state.startedGenerationKeys.has(key)) return
state.startedGenerationKeys.add(key)
const text = buildConversationTitleInput(state.completedTurns)
const runtimeProviderId = runtimeOverrides.get(sessionId)?.providerId
const generationSeq = ++state.generationSeq
void (async () => {
try {
const responseLanguage = await getResponseLanguageSetting()
const titleLanguagePreference = resolveTitleLanguagePreference(
state.firstUserMessage,
responseLanguage,
)
const aiTitle = await generateTitle(
text,
runtimeProviderId,
titleLanguagePreference,
)
if (generationSeq !== state.generationSeq) return
if (aiTitle) {
const saved = await saveAiTitle(sessionId, aiTitle)
if (!saved) {
state.hasCustomTitle = true
return
}
sendSessionTitleUpdated(ws, sessionId, aiTitle)
}
} catch (err) {
console.error(`[Title] Failed to generate title for ${sessionId}:`, err)
}
})()
}
async function getResponseLanguageSetting(): Promise<string | undefined> {
const userSettings = await settingsService.getUserSettings().catch(() => ({}))
return typeof userSettings.language === 'string'
? userSettings.language
: undefined
}
function sendSessionTitleUpdated(
fallbackWs: ServerWebSocket<WebSocketData>,
sessionId: string,
title: string,
): void {
const payload: ServerMessage = { type: 'session_title_updated', sessionId, title }
const clients = activeSessions.get(sessionId)
if (!clients?.size) {
sendMessage(fallbackWs, payload)
return
}
for (const client of clients) {
sendMessage(client, payload)
}
}
function bindTitleSessionOutput(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
shouldProcess: () => boolean,
): () => void {
const callback = (cliMsg: any) => {
if (!shouldProcess() && !(cliMsg?.type === 'result' && cliMsg?.is_error)) {
return
}
appendAssistantTextForTitle(sessionId, cliMsg)
if (cliMsg?.type === 'result') {
conversationService.removeOutputCallback(sessionId, callback)
const completedTurnCount = completeActiveTitleTurn(sessionId)
if (!cliMsg.is_error) {
triggerTitleGeneration(ws, sessionId, 'turn-complete', completedTurnCount ?? undefined)
}
}
}
conversationService.onOutput(sessionId, callback)
return () => conversationService.removeOutputCallback(sessionId, callback)
}
function appendAssistantTextForTitle(sessionId: string, cliMsg: any): void {
const activeTurn = sessionTitleState.get(sessionId)?.activeTurn
if (!activeTurn) return
const streamText = extractAssistantStreamTextForTitle(cliMsg)
if (streamText) {
activeTurn.assistantText = `${activeTurn.assistantText ?? ''}${streamText}`
return
}
const assistantText = extractAssistantMessageTextForTitle(cliMsg)
if (assistantText) {
activeTurn.assistantText = activeTurn.assistantText
? `${activeTurn.assistantText}\n${assistantText}`
: assistantText
return
}
if (
cliMsg?.type === 'result' &&
!cliMsg.is_error &&
!activeTurn.assistantText &&
typeof cliMsg.result === 'string'
) {
activeTurn.assistantText = cliMsg.result
}
}
function extractAssistantStreamTextForTitle(cliMsg: any): string | null {
const event = cliMsg?.event
if (
cliMsg?.type !== 'stream_event' ||
event?.type !== 'content_block_delta' ||
event.delta?.type !== 'text_delta' ||
typeof event.delta.text !== 'string'
) {
return null
}
return event.delta.text
}
function extractAssistantMessageTextForTitle(cliMsg: any): string | null {
if (cliMsg?.type !== 'assistant') return null
const content = cliMsg.message?.content
if (typeof content === 'string') return content
if (!Array.isArray(content)) return null
const text = content
.flatMap((block) => {
if (!block || typeof block !== 'object') return []
const typedBlock = block as { type?: unknown; text?: unknown }
return typedBlock.type === 'text' && typeof typedBlock.text === 'string'
? [typedBlock.text]
: []
})
.join('\n')
.trim()
return text || null
}
function completeActiveTitleTurn(sessionId: string): number | null {
const state = sessionTitleState.get(sessionId)
const activeTurn = state?.activeTurn
if (!state || !activeTurn) return null
state.completedTurns.push({
userText: activeTurn.userText,
assistantText: activeTurn.assistantText?.trim(),
})
state.activeTurn = undefined
return activeTurn.count
}
function discardActiveTitleTurn(sessionId: string, count: number | null): void {
if (count === null) return
const state = sessionTitleState.get(sessionId)
if (state?.activeTurn?.count === count) {
state.activeTurn = undefined
}
}
// ============================================================================
// CLI message translation
// ============================================================================
/**
* Per-session streaming state to avoid cross-session interference.
* Each session tracks its own dedup flag, active block types, and tool blocks.
*/
type SessionStreamState = {
hasReceivedStreamEvents: boolean
activeBlockTypes: Map<number, 'text' | 'tool_use' | 'thinking'>
activeToolBlocks: Map<number, { toolName: string; toolUseId: string; inputJson: string; parentToolUseId?: string }>
pendingLocalCommand?: { name: string; args: string }
/** Tool blocks whose input JSON failed to parse in content_block_stop.
* The assistant message carries the complete input — defer to that. */
pendingToolBlocks: Map<string, { toolName: string; toolUseId: string; parentToolUseId?: string }>
toolParentUseIds: Map<string, string>
lastApiError?: {
message: string
code: string
}
}
const sessionStreamStates = new Map<string, SessionStreamState>()
function getStreamState(sessionId: string): SessionStreamState {
let state = sessionStreamStates.get(sessionId)
if (!state) {
state = {
hasReceivedStreamEvents: false,
activeBlockTypes: new Map(),
activeToolBlocks: new Map(),
pendingLocalCommand: undefined,
pendingToolBlocks: new Map(),
toolParentUseIds: new Map(),
lastApiError: undefined,
}
sessionStreamStates.set(sessionId, state)
}
return state
}
function cliParentToolUseId(cliMsg: any): string | undefined {
return typeof cliMsg.parent_tool_use_id === 'string' && cliMsg.parent_tool_use_id.length > 0
? cliMsg.parent_tool_use_id
: undefined
}
function rememberToolParentUseId(
streamState: SessionStreamState,
toolUseId: string | undefined,
parentToolUseId: string | undefined,
): void {
if (!toolUseId || !parentToolUseId) return
streamState.toolParentUseIds.set(toolUseId, parentToolUseId)
}
function consumeToolParentUseId(
streamState: SessionStreamState,
toolUseId: string | undefined,
): string | undefined {
if (!toolUseId) return undefined
const parentToolUseId = streamState.toolParentUseIds.get(toolUseId)
streamState.toolParentUseIds.delete(toolUseId)
return parentToolUseId
}
/** Clean up stream state when session disconnects */
function cleanupStreamState(sessionId: string) {
sessionStreamStates.delete(sessionId)
}
function cleanupSessionRuntimeState(sessionId: string) {
cancelSessionDisconnectWatcher(sessionId)
cleanupStreamState(sessionId)
sessionSlashCommands.delete(sessionId)
sessionTitleState.delete(sessionId)
runtimeOverrides.delete(sessionId)
activeUserTurns.delete(sessionId)
deferredRuntimeRestarts.delete(sessionId)
deferredPermissionModes.delete(sessionId)
runtimeTransitionPromises.delete(sessionId)
sessionStartupPromises.delete(sessionId)
lastResolvedStartupWorkDirs.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
const turnActive = hasPendingOrActiveUserTurn(sessionId)
const hasClients = hasActiveClients(sessionId)
// Safety guard: never kill a session that has a registered user turn or
// connected clients. The turn-registered check (not messageSent) covers the
// CLI-startup window, so a turn racing through startup is protected even if
// the client has briefly disconnected. The prewarm idle timer is only meant
// to reclaim truly idle prewarmed sessions — not to interrupt a conversation.
if (turnActive || hasClients) {
prewarmedSessions.delete(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 (typeof cliMsg.cwd === 'string' && cliMsg.cwd.trim()) {
conversationService.updateSessionWorkDir(sessionId, cliMsg.cwd)
void (async () => {
await sessionService.appendSessionMetadata(sessionId, {
workDir: cliMsg.cwd,
})
await sessionService.deletePlaceholderSessionFiles(sessionId, cliMsg.cwd)
})()
}
if (cliMsg.slash_commands && Array.isArray(cliMsg.slash_commands)) {
updateSessionSlashCommands(sessionId, cliMsg.slash_commands, { notifyClient: false })
}
}
function extractAssistantText(cliMsg: any): string {
const content = cliMsg?.message?.content
if (!Array.isArray(content)) return ''
const textBlock = content.find(
(block: unknown): block is { type: string; text: string } =>
!!block &&
typeof block === 'object' &&
(block as { type?: unknown }).type === 'text' &&
typeof (block as { text?: unknown }).text === 'string',
)
return textBlock?.text || ''
}
function readObject(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
function normalizeAskUserQuestionToolResult(content: unknown, toolUseResult: unknown): unknown {
const result = readObject(toolUseResult)
const answers = readObject(result?.answers)
if (!result || !answers || !Array.isArray(result.questions)) return content
return {
questions: result.questions,
answers,
}
}
function isDuplicateOfLastApiError(
lastApiError: SessionStreamState['lastApiError'],
resultMessage: string,
): boolean {
if (!lastApiError?.message) return false
if (resultMessage === lastApiError.message) return true
return (
resultMessage.includes(lastApiError.message) &&
/CLI (?:process exited unexpectedly|exited during startup)/i.test(resultMessage)
)
}
function classifyRuntimeErrorCode(message: string, fallbackCode: string): string {
if (/Stream max duration exceeded/i.test(message)) {
return 'STREAM_MAX_DURATION'
}
if (
/Provider stream stalled after partial response/i.test(message) ||
/Stream idle timeout/i.test(message)
) {
return 'STREAM_IDLE_TIMEOUT'
}
return fallbackCode
}
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<string> {
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<WebSocketData>,
sessionId: string,
reason: 'user_message' | 'prewarm_session',
): Promise<void> {
const pendingStartup = sessionStartupPromises.get(sessionId)
if (pendingStartup) {
await pendingStartup
return
}
if (conversationService.hasSession(sessionId)) return
const startupRuntimeVersion = runtimeOverrideVersions.get(sessionId) ?? 0
sessionStartupRuntimeVersions.set(sessionId, startupRuntimeVersion)
const startup = (async () => {
const workDir = await resolveSessionWorkDir(sessionId)
lastResolvedStartupWorkDirs.set(sessionId, workDir)
const runtimeSettings = await getRuntimeSettings(sessionId)
const startupSettings = reason === 'prewarm_session'
? { ...runtimeSettings, resumeInterruptedTurn: false }
: runtimeSettings
const sdkUrl =
`ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` +
`?token=${encodeURIComponent(crypto.randomUUID())}`
await sendRepositoryStartupStatus(ws, sessionId, reason)
console.log(`[WS] Starting CLI for ${sessionId} due to ${reason}`)
await conversationService.startSession(sessionId, workDir, sdkUrl, startupSettings)
})()
sessionStartupPromises.set(sessionId, startup)
try {
await startup
} finally {
if (sessionStartupPromises.get(sessionId) === startup) {
sessionStartupPromises.delete(sessionId)
sessionStartupRuntimeVersions.delete(sessionId)
}
}
}
export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
const streamState = getStreamState(sessionId)
switch (cliMsg.type) {
case 'assistant': {
if (cliMsg.error || cliMsg.isApiErrorMessage) {
// If the user requested stop, suppress API errors caused by the
// stream being interrupted (e.g. "Stream ended without receiving
// any events"). The result message handler also checks this flag,
// but the assistant error arrives first and would leak to the UI.
if (sessionStopRequested.has(sessionId)) {
return []
}
const message = extractAssistantText(cliMsg) || cliMsg.error || 'Unknown API error'
const fallbackCode = typeof cliMsg.error === 'string' ? cliMsg.error : 'API_ERROR'
const code = classifyRuntimeErrorCode(message, fallbackCode)
streamState.lastApiError = { message, code }
return [{
type: 'error',
message,
code,
...(typeof cliMsg.businessErrorCode === 'string'
? { businessErrorCode: cliMsg.businessErrorCode }
: {}),
}]
}
// If we already received stream_events, text/thinking were already sent.
// Only extract tool_use blocks (stream_event's content_block_stop lacks complete tool info).
if (cliMsg.message?.content && Array.isArray(cliMsg.message.content)) {
const messages: ServerMessage[] = []
for (const block of cliMsg.message.content) {
if (streamState.hasReceivedStreamEvents) {
// Stream events handled most blocks — but any tool_use whose
// input JSON failed to parse in content_block_stop was deferred.
// Emit those now with the complete input from the assistant message.
if (block.type === 'tool_use' && streamState.pendingToolBlocks.has(block.id)) {
const pending = streamState.pendingToolBlocks.get(block.id)!
streamState.pendingToolBlocks.delete(block.id)
rememberToolParentUseId(streamState, block.id, pending.parentToolUseId)
messages.push({
type: 'tool_use_complete',
toolName: pending.toolName || block.name,
toolUseId: block.id,
input: block.input,
parentToolUseId: pending.parentToolUseId,
})
}
} else {
// No stream events received — this is the only source, process everything
if (block.type === 'thinking' && block.thinking) {
messages.push({ type: 'thinking', text: block.thinking })
} else if (block.type === 'text' && block.text) {
messages.push({ type: 'content_start', blockType: 'text' })
messages.push({ type: 'content_delta', text: block.text })
} else if (block.type === 'tool_use') {
const parentToolUseId = cliParentToolUseId(cliMsg)
rememberToolParentUseId(streamState, block.id, parentToolUseId)
messages.push({
type: 'tool_use_complete',
toolName: block.name,
toolUseId: block.id,
input: block.input,
parentToolUseId,
})
}
}
}
// Reset flags for next turn
streamState.hasReceivedStreamEvents = false
streamState.pendingToolBlocks.clear()
return messages
}
return []
}
case 'user': {
// Bug #1: 处理 tool_result 消息
// CLI 发送 type:'user' 消息,其中 content 包含 tool_result 块
const messages: ServerMessage[] = []
if (isCompactSummaryMessageContent(cliMsg.message?.content)) {
messages.push({
type: 'system_notification',
subtype: 'compact_summary',
message: cliMsg.message.content,
data: {
isSynthetic: cliMsg.isSynthetic,
},
})
}
const localCommandOutput = extractLocalCommandOutput(
cliMsg.message?.content,
)
if (localCommandOutput) {
const pendingLocalCommand = streamState.pendingLocalCommand
streamState.pendingLocalCommand = undefined
if (!isCompactLocalCommandOutput(localCommandOutput)) {
const goalEvent = extractGoalEvent(
localCommandOutput,
pendingLocalCommand,
)
if (goalEvent) {
messages.push({
type: 'system_notification',
subtype: 'goal_event',
message: goalEvent.message,
data: goalEvent,
})
} else {
messages.push({ type: 'content_start', blockType: 'text' })
messages.push({ type: 'content_delta', text: localCommandOutput })
}
}
}
if (cliMsg.message?.content && Array.isArray(cliMsg.message.content)) {
for (const block of cliMsg.message.content) {
if (block.type === 'tool_result') {
const rememberedParentToolUseId = consumeToolParentUseId(streamState, block.tool_use_id)
const parentToolUseId =
cliParentToolUseId(cliMsg) ?? rememberedParentToolUseId
messages.push({
type: 'tool_result',
toolUseId: block.tool_use_id,
content: normalizeAskUserQuestionToolResult(block.content, cliMsg.toolUseResult),
isError: !!block.is_error,
parentToolUseId,
})
}
}
}
const replayText = extractReplayUserText(cliMsg)
if (replayText) {
messages.push({
type: 'user_message_replay',
content: replayText,
})
}
return messages
}
case 'stream_event': {
streamState.hasReceivedStreamEvents = true
const event = cliMsg.event
if (!event) return []
switch (event.type) {
case 'message_start': {
return [{ type: 'status', state: 'thinking' }]
}
case 'content_block_start': {
const contentBlock = event.content_block
if (!contentBlock) return []
const index = event.index ?? 0
if (contentBlock.type === 'tool_use') {
const parentToolUseId = cliParentToolUseId(cliMsg)
streamState.activeBlockTypes.set(index, 'tool_use')
// Track tool info so content_block_stop can emit complete data
streamState.activeToolBlocks.set(index, {
toolName: contentBlock.name || '',
toolUseId: contentBlock.id || '',
inputJson: '',
parentToolUseId,
})
return [{
type: 'content_start',
blockType: 'tool_use',
toolName: contentBlock.name,
toolUseId: contentBlock.id,
parentToolUseId,
}]
}
if (contentBlock.type === 'thinking' || contentBlock.type === 'redacted_thinking') {
streamState.activeBlockTypes.set(index, 'thinking')
return [{ type: 'status', state: 'thinking', verb: 'Thinking' }]
}
streamState.activeBlockTypes.set(index, 'text')
return [{ type: 'content_start', blockType: 'text' }]
}
case 'content_block_delta': {
const delta = event.delta
if (!delta) return []
if (delta.type === 'text_delta' && delta.text) {
return [{ type: 'content_delta', text: delta.text }]
}
if (delta.type === 'input_json_delta' && delta.partial_json) {
// Accumulate tool input JSON
const index = event.index ?? 0
const toolBlock = streamState.activeToolBlocks.get(index)
if (toolBlock) toolBlock.inputJson += delta.partial_json
return [{ type: 'content_delta', toolInput: delta.partial_json }]
}
if (delta.type === 'thinking_delta' && delta.thinking) {
return [{ type: 'thinking', text: delta.thinking }]
}
return []
}
case 'content_block_stop': {
const index = event.index ?? 0
const blockType = streamState.activeBlockTypes.get(index)
streamState.activeBlockTypes.delete(index)
if (blockType === 'tool_use') {
const toolBlock = streamState.activeToolBlocks.get(index)
streamState.activeToolBlocks.delete(index)
if (toolBlock) {
const parentToolUseId =
cliParentToolUseId(cliMsg) ?? toolBlock.parentToolUseId
let parsedInput = null
try { parsedInput = JSON.parse(toolBlock.inputJson) } catch {}
if (parsedInput !== null) {
rememberToolParentUseId(streamState, toolBlock.toolUseId, parentToolUseId)
return [{
type: 'tool_use_complete',
toolName: toolBlock.toolName,
toolUseId: toolBlock.toolUseId,
input: parsedInput,
parentToolUseId,
}]
}
// JSON parse failed — defer to the assistant message which
// carries the complete, already-parsed tool input. This is the
// normal streaming partial-input case, not a fault: keep it at
// debug so it doesn't surface as a diagnostics warning.
console.debug(
`[WS] Tool input JSON parse failed for ${toolBlock.toolName} (${toolBlock.toolUseId}), deferring to assistant message`,
)
streamState.pendingToolBlocks.set(toolBlock.toolUseId, {
toolName: toolBlock.toolName,
toolUseId: toolBlock.toolUseId,
parentToolUseId,
})
}
}
return []
}
case 'message_stop': {
// message_stop is handled by the 'result' message
return []
}
case 'message_delta': {
// message_delta may contain stop_reason or usage updates
return []
}
default:
return []
}
}
case 'control_request': {
// 权限请求 — CLI 需要用户授权才能执行工具
if (cliMsg.request?.subtype === 'can_use_tool') {
return [{
type: 'permission_request',
requestId: cliMsg.request_id,
toolName: cliMsg.request.tool_name || 'Unknown',
toolUseId:
typeof cliMsg.request.tool_use_id === 'string'
? cliMsg.request.tool_use_id
: undefined,
input: cliMsg.request.input || {},
description: cliMsg.request.description,
}]
}
return []
}
case 'control_response':
return []
case 'result': {
// 对话结果(成功或错误)
const usage = translateCliUsage(cliMsg.usage)
if (cliMsg.is_error) {
// If the user requested stop, this "error" is just the interrupt
// result — don't show it as an error in the chat UI.
if (sessionStopRequested.has(sessionId)) {
sessionStopRequested.delete(sessionId)
return [{ type: 'message_complete', usage }]
}
const resultMessage =
(typeof cliMsg.result === 'string' && cliMsg.result) ||
(Array.isArray(cliMsg.errors) && cliMsg.errors.length > 0
? cliMsg.errors.join('\n')
: 'Unknown error')
if (isDuplicateOfLastApiError(streamState.lastApiError, resultMessage)) {
streamState.lastApiError = undefined
return [{ type: 'message_complete', usage }]
}
// 错误和完成消息都发送
return [
{
type: 'error',
message: resultMessage,
code: classifyRuntimeErrorCode(resultMessage, 'CLI_ERROR'),
},
{ type: 'message_complete', usage },
]
}
// Clear stop flag on successful completion too
sessionStopRequested.delete(sessionId)
streamState.lastApiError = undefined
return [{ type: 'message_complete', usage }]
}
case 'system': {
// 区分不同的 system 子类型
const subtype = cliMsg.subtype
if (subtype === 'api_retry') {
const apiRetryMessage = toApiRetryServerMessage(cliMsg)
return apiRetryMessage ? [apiRetryMessage] : []
}
if (subtype === 'streaming_fallback') {
return [toStreamingFallbackServerMessage(cliMsg)]
}
if (subtype === 'init') {
// CLI 初始化完成 — 缓存 slash commands 并发送模型信息
// 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.
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 } },
]
// Send slash commands to frontend
const cmds = sessionSlashCommands.get(sessionId)
if (cmds && cmds.length > 0) {
messages.push({
type: 'system_notification',
subtype: 'slash_commands',
data: cmds,
})
}
return messages
}
if (subtype === 'memory_saved') {
return [{
type: 'system_notification',
subtype: 'memory_saved',
message: cliMsg.message,
data: {
writtenPaths: Array.isArray(cliMsg.writtenPaths) ? cliMsg.writtenPaths : [],
teamCount: typeof cliMsg.teamCount === 'number' ? cliMsg.teamCount : undefined,
verb: typeof cliMsg.verb === 'string' ? cliMsg.verb : undefined,
},
}]
}
if (subtype === 'status') {
if (cliMsg.status === 'compacting') {
return [{
type: 'status',
state: 'compacting',
verb: 'Compacting conversation',
}]
}
// CLI 在权限模式变化时也会 enqueue 一条 status 事件status:null +
// permissionMode用于把恢复后的真实权限如 ExitPlanMode 退出 plan、
// Shift+Tab广播给前端。它带 status:null 但**不是** thinking 信号,
// 必须在下面的 null→thinking 兜底之前拦截,否则字段会被丢弃,桌面端
// 选择器就会一直卡在"计划模式"。
if (typeof cliMsg.permissionMode === 'string') {
return [{ type: 'permission_mode_changed', mode: cliMsg.permissionMode }]
}
if (cliMsg.status == null) {
return [{ type: 'status', state: 'thinking', verb: 'Thinking' }]
}
return []
}
if (subtype === 'hook_started' || subtype === 'hook_response') {
// Hook 执行中 — 不转发给前端
return []
}
if (subtype === 'local_command' || subtype === 'local_command_output') {
const localCommand = extractLocalCommand(cliMsg.content ?? cliMsg.message)
if (localCommand) {
streamState.pendingLocalCommand = localCommand
return []
}
const localCommandOutput = extractLocalCommandOutput(
cliMsg.content ?? cliMsg.message,
{ allowUntagged: subtype === 'local_command_output' },
)
if (!localCommandOutput) return []
const goalEvent = extractGoalEvent(
localCommandOutput,
streamState.pendingLocalCommand,
)
streamState.pendingLocalCommand = undefined
if (goalEvent) {
return [{
type: 'system_notification',
subtype: 'goal_event',
message: goalEvent.message,
data: goalEvent,
}]
}
return [
{ type: 'content_start', blockType: 'text' },
{ type: 'content_delta', text: localCommandOutput },
]
}
// Bug #7: 处理 task/team system 消息
if (subtype === 'task_notification') {
return [{
type: 'system_notification',
subtype: 'task_notification',
message: cliMsg.message || cliMsg.title,
data: cliMsg,
}]
}
if (subtype === 'task_started') {
return [
{
type: 'system_notification',
subtype: 'task_started',
message: cliMsg.message || cliMsg.description || 'Task started',
data: cliMsg,
},
{
type: 'status',
state: 'tool_executing',
verb: cliMsg.message || cliMsg.description || 'Task started',
},
]
}
if (subtype === 'task_progress') {
return [
{
type: 'system_notification',
subtype: 'task_progress',
message: cliMsg.message || cliMsg.summary || cliMsg.description || 'Task in progress',
data: cliMsg,
},
{
type: 'status',
state: 'tool_executing',
verb: cliMsg.message || cliMsg.summary || cliMsg.description || 'Task in progress',
},
]
}
if (subtype === 'agent_tool_activity') {
// Tool activity streamed from a background (async) agent. Re-emit as a
// normal tool_use_complete / tool_result carrying the parent Agent
// tool_use_id, so the desktop groups it under the agent card exactly
// like a synchronous subagent (childToolCallsByParent).
const activity = cliMsg.activity
const parentToolUseId =
typeof cliMsg.tool_use_id === 'string' ? cliMsg.tool_use_id : undefined
if (activity?.kind === 'tool_use') {
return [{
type: 'tool_use_complete',
toolName: activity.tool_name,
toolUseId: activity.tool_use_id,
input: activity.input,
parentToolUseId,
}]
}
if (activity?.kind === 'tool_result') {
return [{
type: 'tool_result',
toolUseId: activity.tool_use_id,
content: activity.content,
isError: activity.is_error === true,
parentToolUseId,
}]
}
return []
}
if (subtype === 'session_state_changed') {
return [{
type: 'system_notification',
subtype: 'session_state_changed',
message: cliMsg.message,
data: cliMsg,
}]
}
if (subtype === 'compact_boundary') {
return [{
type: 'system_notification',
subtype: 'compact_boundary',
message: getCompactBoundaryMessage(cliMsg),
data: cliMsg.compact_metadata ?? cliMsg,
}]
}
// 其他 system 消息
return []
}
default:
// 未知类型 — 调试输出但不转发
console.log(`[WS] Unknown CLI message type: ${cliMsg.type}`, JSON.stringify(cliMsg).substring(0, 200))
return []
}
}
// ============================================================================
// Helpers
// ============================================================================
function finiteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function normalizeRetryCount(value: unknown): number | null {
const numeric = finiteNumber(value)
if (numeric === null) return null
return Math.max(0, Math.trunc(numeric))
}
function readRetryErrorRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
function readRetryErrorString(value: unknown, keys: string[]): string | undefined {
const record = readRetryErrorRecord(value)
if (!record) return undefined
for (const key of keys) {
const candidate = record[key]
if (typeof candidate === 'string' && candidate.trim()) return candidate.trim()
}
return undefined
}
function toApiRetryServerMessage(cliMsg: any): ServerMessage | null {
const attempt = normalizeRetryCount(cliMsg.attempt)
const maxRetries = normalizeRetryCount(cliMsg.max_retries)
const retryDelayMs = normalizeRetryCount(cliMsg.retry_delay_ms)
if (attempt === null || maxRetries === null || retryDelayMs === null) return null
const embeddedError = readRetryErrorRecord(cliMsg.error)
const embeddedStatus = embeddedError ? finiteNumber(embeddedError.status) : null
const rawStatus = cliMsg.error_status === null
? null
: finiteNumber(cliMsg.error_status) ?? embeddedStatus
const errorType = typeof cliMsg.error === 'string' && cliMsg.error.trim()
? cliMsg.error.trim()
: readRetryErrorString(cliMsg.error, ['type', 'code', 'name'])
const errorMessage = readRetryErrorString(cliMsg.error, ['message', 'error'])
return {
type: 'api_retry',
attempt,
maxRetries,
retryDelayMs,
errorStatus: rawStatus === null ? null : Math.trunc(rawStatus),
...(errorType ? { errorType } : {}),
...(errorMessage ? { errorMessage } : {}),
}
}
const STREAMING_FALLBACK_CAUSES: ReadonlySet<StreamingFallbackCause> = new Set([
'watchdog',
'stream_error',
'404_stream_creation',
])
function toStreamingFallbackServerMessage(cliMsg: any): ServerMessage {
// 未识别的 cause 兜底为 unknown 而不是丢消息:提示本身比成因重要。
const cause: StreamingFallbackCause =
typeof cliMsg.cause === 'string' && STREAMING_FALLBACK_CAUSES.has(cliMsg.cause as StreamingFallbackCause)
? (cliMsg.cause as StreamingFallbackCause)
: 'unknown'
return { type: 'streaming_fallback', cause }
}
function sendMessage(ws: ServerWebSocket<WebSocketData>, message: ServerMessage) {
ws.send(JSON.stringify(message))
}
function sendError(ws: ServerWebSocket<WebSocketData>, message: string, code: string) {
sendMessage(ws, { type: 'error', message, code })
}
/**
* Idle disconnect cleanup delay. A session waiting on a pending permission
* keeps the long 30-minute window so a transient renderer disconnect does not
* abort a prompt the user is about to answer. Otherwise we honor the
* user-configured grace period (issue #764).
*/
function getDisconnectCleanupDelayMs(sessionId: string): number {
return conversationService.getPendingPermissionRequests(sessionId).length > 0
? PENDING_PERMISSION_DISCONNECT_CLEANUP_MS
: getDisconnectGraceMs()
}
/**
* Whether the session is mid-turn (a user message was sent and no result has
* arrived yet). Such a turn must not be killed on disconnect.
*/
function isSessionTurnActive(sessionId: string): boolean {
return activeUserTurns.get(sessionId)?.messageSent === true
}
/**
* Whether a user turn has been registered for this session and not yet settled,
* INCLUDING the CLI-startup window before messageSent flips true. handleUserMessage
* registers the turn in its synchronous prefix (activeUserTurns.set), well before
* the message is actually sent. Unlike isSessionTurnActive, this is not blind to
* that window, so the prewarm idle timer can neither arm on nor fire against a
* session a user turn has already claimed — even when a concurrent
* prewarm_session/user_message flush inverts their ordering.
*/
function hasPendingOrActiveUserTurn(sessionId: string): boolean {
return activeUserTurns.has(sessionId)
}
/**
* Start the idle grace timer for a disconnected, idle session. If no client
* reconnects before it fires, the CLI subprocess is stopped.
*/
function scheduleDisconnectCleanup(sessionId: string): void {
computerUseApprovalService.cancelSession(sessionId)
const existing = sessionCleanupTimers.get(sessionId)
if (existing) clearTimeout(existing)
const cleanupDelayMs = getDisconnectCleanupDelayMs(sessionId)
const cleanupTimer = setTimeout(() => {
sessionCleanupTimers.delete(sessionId)
if (!hasActiveClients(sessionId)) {
console.log(`[WS] Session ${sessionId} not reconnected after ${cleanupDelayMs}ms, stopping CLI subprocess`)
conversationService.stopSession(sessionId)
cleanupSessionRuntimeState(sessionId)
}
}, cleanupDelayMs)
sessionCleanupTimers.set(sessionId, cleanupTimer)
}
/**
* Keep a still-running session alive after the last client leaves, and start
* the idle grace timer only once the current turn completes (issue #764). If a
* client reconnects first, cancelSessionDisconnectWatcher() tears this down.
*/
function watchTurnCompletionForCleanup(sessionId: string): void {
cancelSessionDisconnectWatcher(sessionId)
const onComplete = (cliMsg: any) => {
if (cliMsg?.type !== 'result') return
cancelSessionDisconnectWatcher(sessionId)
// The turn finished while still unobserved — fall back to the idle timer.
if (!hasActiveClients(sessionId)) {
scheduleDisconnectCleanup(sessionId)
}
}
conversationService.onOutput(sessionId, onComplete)
sessionDisconnectWatchers.set(sessionId, () => {
conversationService.removeOutputCallback(sessionId, onComplete)
})
}
/** Remove any pending turn-completion watcher for a session. */
function cancelSessionDisconnectWatcher(sessionId: string): void {
const remove = sessionDisconnectWatchers.get(sessionId)
if (remove) {
remove()
sessionDisconnectWatchers.delete(sessionId)
}
}
function replayPendingPermissionRequests(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
): void {
for (const request of conversationService.getPendingPermissionRequests(sessionId)) {
sendMessage(ws, {
type: 'permission_request',
requestId: request.requestId,
toolName: request.toolName,
...(request.toolUseId ? { toolUseId: request.toolUseId } : {}),
input: request.input,
...(request.description ? { description: request.description } : {}),
})
}
}
function getDesktopSlashCommand(content: string): ReturnType<typeof parseSlashCommand> {
const parsed = parseSlashCommand(content.trim())
if (!parsed || parsed.isMcp) return null
return parsed
}
function getTitleInputForUserMessage(
content: string,
command: ReturnType<typeof parseSlashCommand>,
): string | null {
if (command?.commandName !== 'goal') return content
const args = command.args.trim()
if (!args || args === 'clear') return null
return args
}
export function createCurrentTurnLocalCommandForwarder(
command: ReturnType<typeof parseSlashCommand>,
): (cliMsg: any) => boolean {
let awaitingCurrentTurnLocalCommandOutput = false
return (cliMsg: any) => {
if (command && isMatchingCurrentTurnLocalCommand(cliMsg, command)) {
awaitingCurrentTurnLocalCommandOutput = true
return true
}
if (command?.commandName === 'goal' && isLocalCommandOutputMessage(cliMsg)) {
const output = extractLocalCommandOutput(
cliMsg.content ?? cliMsg.message,
{ allowUntagged: cliMsg.subtype === 'local_command_output' },
)
if (output && looksLikeGoalCommandOutput(output)) {
awaitingCurrentTurnLocalCommandOutput = false
return true
}
}
if (
awaitingCurrentTurnLocalCommandOutput &&
isLocalCommandOutputMessage(cliMsg)
) {
awaitingCurrentTurnLocalCommandOutput = false
return true
}
return false
}
}
function isMatchingCurrentTurnLocalCommand(
cliMsg: any,
command: NonNullable<ReturnType<typeof parseSlashCommand>>,
): boolean {
if (cliMsg?.type !== 'system' || cliMsg?.subtype !== 'local_command') {
return false
}
const localCommand = extractLocalCommand(cliMsg.content ?? cliMsg.message)
if (!localCommand) return false
return (
localCommand.name === command.commandName &&
localCommand.args.trim() === command.args.trim()
)
}
function isLocalCommandOutputMessage(cliMsg: any): boolean {
if (
cliMsg?.type !== 'system' ||
(cliMsg?.subtype !== 'local_command' &&
cliMsg?.subtype !== 'local_command_output')
) {
return false
}
return extractLocalCommandOutput(
cliMsg.content ?? cliMsg.message,
{ allowUntagged: cliMsg.subtype === 'local_command_output' },
) !== null
}
function extractLocalCommandOutput(
content: unknown,
options: { allowUntagged?: boolean } = {},
): string | null {
const raw = typeof content === 'string'
? content
: Array.isArray(content)
? content
.flatMap((block) => {
if (!block || typeof block !== 'object') return []
const text = (block as { text?: unknown }).text
return typeof text === 'string' ? [text] : []
})
.join('\n')
: ''
if (!raw) return null
const stdout = extractTaggedContent(raw, LOCAL_COMMAND_STDOUT_TAG)
if (stdout !== null) return stdout
const stderr = extractTaggedContent(raw, LOCAL_COMMAND_STDERR_TAG)
if (stderr !== null) return stderr
if (options.allowUntagged) {
const normalized = raw.trim()
return normalized || null
}
return null
}
function isCompactLocalCommandOutput(output: string): boolean {
return output.trim() === 'Compacted'
}
function extractTaggedContent(raw: string, tag: string): string | null {
const match = raw.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))
return match?.[1]?.trim() ?? null
}
function extractLocalCommand(content: unknown): { name: string; args: string } | null {
const raw = typeof content === 'string'
? content
: Array.isArray(content)
? content
.flatMap((block) => {
if (!block || typeof block !== 'object') return []
const text = (block as { text?: unknown }).text
return typeof text === 'string' ? [text] : []
})
.join('\n')
: ''
const name = extractTaggedContent(raw, COMMAND_NAME_TAG)
if (!name) return null
return {
name: name.replace(/^\//, ''),
args: extractTaggedContent(raw, 'command-args') ?? '',
}
}
type GoalEventData = {
action: 'created' | 'replaced' | 'status' | 'paused' | 'resumed' | 'completed' | 'cleared' | 'message'
status?: string
objective?: string
budget?: string
elapsed?: string
continuations?: string
message?: string
}
function extractGoalEvent(
output: string,
command?: { name: string; args: string },
): GoalEventData | null {
if (command && command.name !== 'goal') return null
const trimmed = output.trim()
if (!trimmed) return null
if (trimmed === 'Goal cleared.' || trimmed.startsWith('Goal cleared:')) {
return { action: 'cleared', message: trimmed }
}
if (trimmed === 'Goal marked complete.') {
return { action: 'completed', message: trimmed }
}
if (trimmed === 'No active goal.') {
return { action: 'message', message: trimmed }
}
if (trimmed.startsWith('Goal continuing:')) {
return {
action: 'status',
status: 'continuing',
message: trimmed,
}
}
if (trimmed.startsWith('Goal set:')) {
const objective = trimmed.slice('Goal set:'.length).trim()
return {
action: 'created',
status: 'active',
objective: objective || undefined,
message: trimmed,
}
}
return command?.name === 'goal' ? { action: 'message', message: trimmed } : null
}
function looksLikeGoalCommandOutput(output: string): boolean {
const trimmed = output.trim()
return (
trimmed.startsWith('Goal set:') ||
trimmed.startsWith('Goal continuing:') ||
trimmed.startsWith('Goal cleared:') ||
trimmed === 'Goal cleared.' ||
trimmed === 'Goal marked complete.' ||
trimmed === 'No active goal.'
)
}
function getCompactBoundaryMessage(cliMsg: any): string {
const message = typeof cliMsg?.message === 'string' ? cliMsg.message.trim() : ''
if (message) return message
const content = typeof cliMsg?.content === 'string' ? cliMsg.content.trim() : ''
if (content) return content
return 'Context compacted'
}
function isCompactSummaryMessageContent(content: unknown): content is string {
return (
typeof content === 'string' &&
content.trim().startsWith(
'This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.',
)
)
}
function hasToolResultBlock(content: unknown): boolean {
return Array.isArray(content) &&
content.some((block) =>
Boolean(block) &&
typeof block === 'object' &&
(block as { type?: unknown }).type === 'tool_result')
}
function extractReplayUserText(cliMsg: any): string | null {
if (cliMsg?.isReplay !== true) return null
const content = cliMsg.message?.content
const commandDisplayText = getCommandMetadataDisplayText(content)
if (commandDisplayText) return commandDisplayText
if (shouldHideCommandMetadataContent(content)) return null
if (isCompactSummaryMessageContent(content)) return null
if (hasToolResultBlock(content)) return null
if (extractLocalCommandOutput(content)) return null
const text = typeof content === 'string'
? content
: Array.isArray(content)
? content
.flatMap((block) => {
if (!block || typeof block !== 'object') return []
const typedBlock = block as { type?: unknown; text?: unknown }
return typedBlock.type === 'text' && typeof typedBlock.text === 'string'
? [typedBlock.text]
: []
})
.join('\n')
: ''
const trimmed = text.trim()
return trimmed || null
}
function addActiveClient(
sessionId: string,
ws: ServerWebSocket<WebSocketData>,
): void {
let clients = activeSessions.get(sessionId)
if (!clients) {
clients = new Set()
activeSessions.set(sessionId, clients)
}
clients.add(ws)
}
function removeActiveClient(
sessionId: string,
ws: ServerWebSocket<WebSocketData>,
): boolean {
const clients = activeSessions.get(sessionId)
if (!clients?.has(ws)) return false
clients.delete(ws)
if (clients.size === 0) {
activeSessions.delete(sessionId)
}
return true
}
function hasActiveClients(sessionId: string): boolean {
return (activeSessions.get(sessionId)?.size ?? 0) > 0
}
function removeClientOutputCallback(ws: ServerWebSocket<WebSocketData>): void {
const entry = clientOutputCallbacks.get(ws)
if (!entry) return
conversationService.removeOutputCallback(entry.sessionId, entry.callback)
clientOutputCallbacks.delete(ws)
}
function bindAllClientSessionOutputs(
sessionId: string,
options?: {
shouldForward?: (cliMsg: any) => boolean
},
): void {
const clients = activeSessions.get(sessionId)
if (!clients) return
for (const ws of clients) {
bindClientSessionOutput(sessionId, ws, options)
}
}
function bindClientSessionOutput(
sessionId: string,
ws: ServerWebSocket<WebSocketData>,
options?: {
shouldForward?: (cliMsg: any) => boolean
},
) {
if (!conversationService.hasSession(sessionId)) return
removeClientOutputCallback(ws)
const callback = (cliMsg: any) => {
if (options?.shouldForward && !options.shouldForward(cliMsg)) {
return
}
handleCliPermissionModeBroadcast(sessionId, cliMsg)
const serverMsgs = translateCliMessage(cliMsg, sessionId)
for (const msg of serverMsgs) {
sendMessage(ws, msg)
}
}
clientOutputCallbacks.set(ws, { sessionId, callback })
conversationService.onOutput(sessionId, callback)
}
function getCliPermissionModeBroadcast(cliMsg: any): string | null {
if (
cliMsg?.type === 'system' &&
cliMsg.subtype === 'status' &&
typeof cliMsg.permissionMode === 'string'
) {
return cliMsg.permissionMode
}
return null
}
function handleCliPermissionModeBroadcast(sessionId: string, cliMsg: any): void {
const mode = getCliPermissionModeBroadcast(cliMsg)
if (!mode) return
const currentMode = conversationService.getSessionPermissionMode(sessionId)
if (currentMode === mode) return
if (!conversationService.recordSessionPermissionMode(sessionId, mode)) return
void persistSessionPermissionMode(sessionId, mode).catch((err) => {
console.warn(`[WS] Failed to persist CLI permission mode broadcast for ${sessionId}:`, err)
})
}
type RuntimeSettings = {
permissionMode?: string
model?: string
effort?: string
thinking?: 'disabled'
providerId?: string | null
}
function isKnownRuntimeProviderId(
providerId: string,
providers: Array<{ id: string }>,
): boolean {
return (
isOpenAIOfficialProviderId(providerId) ||
providers.some((provider) => provider.id === providerId)
)
}
async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings> {
const launchInfo = sessionId
? await sessionService.getSessionLaunchInfo(sessionId).catch(() => null)
: null
const sessionPermissionMode = sessionId
? launchInfo?.permissionMode ?? await getSessionPermissionMode(sessionId)
: undefined
const persistedRuntimeOverride =
launchInfo?.runtimeModelId
? {
providerId: launchInfo.runtimeProviderId ?? null,
modelId: launchInfo.runtimeModelId,
...(launchInfo.effortLevel ? { effort: launchInfo.effortLevel } : {}),
}
: undefined
const runtimeOverride = sessionId
? runtimeOverrides.get(sessionId) ?? persistedRuntimeOverride
: undefined
if (runtimeOverride) {
if (typeof runtimeOverride.providerId === 'string') {
const { providers } = await providerService.listProviders()
const providerExists = isKnownRuntimeProviderId(runtimeOverride.providerId, providers)
if (!providerExists) {
console.warn(
`[WS] Ignoring stale runtime provider id for ${sessionId}: ${runtimeOverride.providerId}`,
)
runtimeOverrides.delete(sessionId!)
const defaults = await getDefaultRuntimeSettings()
return {
...defaults,
permissionMode: sessionPermissionMode ?? defaults.permissionMode,
}
}
}
const userSettings = await settingsService.getUserSettings()
const thinking = resolveDesktopThinkingMode(userSettings)
return {
permissionMode: sessionPermissionMode ?? await settingsService.getPermissionMode().catch(() => undefined),
model: runtimeOverride.modelId,
effort: runtimeOverride.effort,
thinking,
providerId: runtimeOverride.providerId,
}
}
const defaults = await getDefaultRuntimeSettings()
return {
...defaults,
permissionMode: sessionPermissionMode ?? defaults.permissionMode,
effort: launchInfo?.effortLevel ?? defaults.effort,
}
}
async function getSessionPermissionMode(sessionId: string): Promise<string | undefined> {
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null)
return launchInfo?.permissionMode
}
async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
// Check if a custom provider is active
const { providers, activeId } = await providerService.listProviders()
let resolvedActiveId = activeId
if (activeId && !isKnownRuntimeProviderId(activeId, providers)) {
console.warn(`[WS] Active provider id is stale, falling back to official provider: ${activeId}`)
resolvedActiveId = null
await providerService.activateOfficial()
}
const userSettings = await settingsService.getUserSettings()
const providerSettings = resolvedActiveId
? await providerService.getManagedSettings()
: undefined
const modelSettings = providerSettings ?? userSettings
const modelContext =
typeof modelSettings.modelContext === 'string' && modelSettings.modelContext.trim()
? modelSettings.modelContext
: undefined
const effort =
typeof userSettings.effort === 'string' && userSettings.effort.trim()
? userSettings.effort
: undefined
const thinking = resolveDesktopThinkingMode(userSettings)
let model: string | undefined
if (resolvedActiveId) {
// Provider is active — only consult provider-managed cc-haha settings.
// Global ~/.claude/settings.json model values must not bleed into provider mode.
const baseModel =
typeof modelSettings.model === 'string' && modelSettings.model.trim()
? modelSettings.model
: ''
if (baseModel) {
model = baseModel
if (modelContext) model += `:${modelContext}`
}
} else {
// No provider — pass model normally
const baseModel =
typeof userSettings.model === 'string' && userSettings.model.trim()
? userSettings.model
: undefined
model = baseModel ? (modelContext ? `${baseModel}:${modelContext}` : baseModel) : undefined
}
return {
permissionMode: await settingsService.getPermissionMode().catch(() => undefined),
model,
effort,
thinking,
providerId: resolvedActiveId,
}
}
function resolveDesktopThinkingMode(
settings: Record<string, unknown>,
): 'disabled' | undefined {
return settings.alwaysThinkingEnabled === false ? 'disabled' : undefined
}
async function buildSessionStartupDiagnosticMessage(
sessionId: string,
cause: string,
): Promise<string> {
const lines = [
cause,
'',
'Desktop service diagnostics:',
`- sessionId: ${sessionId}`,
]
try {
const recentWorkDir = lastResolvedStartupWorkDirs.get(sessionId)
const workDir =
recentWorkDir ||
conversationService.getSessionWorkDir(sessionId) ||
await sessionService.getSessionWorkDir(sessionId)
lines.push(`- workDir: ${workDir ?? '(unknown)'}`)
} catch (err) {
lines.push(`- workDir: failed to resolve (${err instanceof Error ? err.message : String(err)})`)
}
const runtimeOverride = runtimeOverrides.get(sessionId)
if (runtimeOverride) {
lines.push(`- runtimeOverride.providerId: ${runtimeOverride.providerId ?? '(official)'}`)
lines.push(`- runtimeOverride.modelId: ${runtimeOverride.modelId}`)
lines.push(`- runtimeOverride.effort: ${runtimeOverride.effort ?? '(auto)'}`)
} else {
lines.push('- runtimeOverride: (none)')
}
try {
const { providers, activeId } = await providerService.listProviders()
lines.push(`- activeProviderId: ${activeId ?? '(official)'}`)
lines.push(`- configuredProviders: ${providers.length}`)
if (providers.length > 0) {
lines.push(
`- providerIndex: ${providers
.map((provider) => `${provider.name} (${provider.id})`)
.join(', ')}`,
)
}
} catch (err) {
lines.push(`- providers: failed to read (${err instanceof Error ? err.message : String(err)})`)
}
return lines.join('\n')
}
function enqueueRuntimeTransition(
sessionId: string,
transition: () => Promise<void>,
): Promise<void> {
const previous = runtimeTransitionPromises.get(sessionId) ?? Promise.resolve()
const next = previous
.catch(() => {})
.then(transition)
.finally(() => {
if (runtimeTransitionPromises.get(sessionId) === next) {
runtimeTransitionPromises.delete(sessionId)
}
})
runtimeTransitionPromises.set(sessionId, next)
return next
}
async function waitForRuntimeTransitionBeforeUserTurn(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
): Promise<{ ok: boolean; waited: boolean }> {
let waited = false
let pendingRuntimeTransition = runtimeTransitionPromises.get(sessionId)
while (pendingRuntimeTransition) {
waited = true
try {
await pendingRuntimeTransition
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err)
void diagnosticsService.recordEvent({
type: 'runtime_transition_failed',
severity: 'error',
sessionId,
summary: errMsg,
details: err,
})
console.error(`[WS] Runtime transition failed before handling user message for ${sessionId}: ${errMsg}`)
sendMessage(ws, {
type: 'error',
message: `Failed to switch provider/model: ${errMsg}`,
code: 'CLI_RESTART_FAILED',
})
sendMessage(ws, { type: 'status', state: 'idle' })
return { ok: false, waited }
}
const nextTransition = runtimeTransitionPromises.get(sessionId)
pendingRuntimeTransition =
nextTransition && nextTransition !== pendingRuntimeTransition
? nextTransition
: undefined
}
return { ok: true, waited }
}
/**
* Send a message to a specific session's WebSocket (for use by services)
*/
export function sendToSession(sessionId: string, message: ServerMessage): boolean {
const clients = activeSessions.get(sessionId)
if (!clients || clients.size === 0) return false
const payload = JSON.stringify(message)
for (const ws of clients) {
ws.send(payload)
}
return true
}
export function updateSessionSlashCommands(
sessionId: string,
commands: unknown[],
options: { notifyClient?: boolean } = {},
): SessionSlashCommand[] {
const normalized = commands
.map(normalizeSessionSlashCommand)
.filter((command): command is SessionSlashCommand => command !== null)
sessionSlashCommands.set(sessionId, normalized)
if (options.notifyClient !== false) {
sendToSession(sessionId, {
type: 'system_notification',
subtype: 'slash_commands',
data: normalized,
})
}
return normalized
}
function normalizeSessionSlashCommand(command: unknown): SessionSlashCommand | null {
if (typeof command === 'string') {
return command.trim() ? { name: command, description: '' } : null
}
if (!command || typeof command !== 'object') return null
const record = command as {
name?: unknown
command?: unknown
description?: unknown
argumentHint?: unknown
}
const name =
typeof record.name === 'string'
? record.name
: typeof record.command === 'string'
? record.command
: ''
if (!name.trim()) return null
return {
name,
description: typeof record.description === 'string' ? record.description : '',
...(typeof record.argumentHint === 'string' ? { argumentHint: record.argumentHint } : {}),
}
}
export function closeSessionConnection(sessionId: string, reason = 'session closed'): boolean {
const cleanupTimer = sessionCleanupTimers.get(sessionId)
if (cleanupTimer) {
clearTimeout(cleanupTimer)
sessionCleanupTimers.delete(sessionId)
}
computerUseApprovalService.cancelSession(sessionId)
conversationService.clearOutputCallbacks(sessionId)
cleanupSessionRuntimeState(sessionId)
const clients = activeSessions.get(sessionId)
if (!clients || clients.size === 0) return false
activeSessions.delete(sessionId)
for (const ws of clients) {
clientOutputCallbacks.delete(ws)
ws.close(1000, reason)
}
return true
}
export function getActiveSessionIds(): string[] {
return Array.from(activeSessions.keys())
}
export function __resetWebSocketHandlerStateForTests(): void {
for (const timer of sessionCleanupTimers.values()) clearTimeout(timer)
for (const timer of prewarmIdleTimers.values()) clearTimeout(timer)
for (const remove of sessionDisconnectWatchers.values()) remove()
activeSessions.clear()
clientOutputCallbacks.clear()
sessionCleanupTimers.clear()
sessionDisconnectWatchers.clear()
prewarmPendingSessions.clear()
prewarmedSessions.clear()
prewarmIdleTimers.clear()
}
export function __markPrewarmPendingForTests(sessionId: string): void {
prewarmPendingSessions.add(sessionId)
}
/** Test hook: mark a session as mid-turn so disconnect keeps the CLI alive. */
export function __markActiveTurnForTests(sessionId: string): void {
activeUserTurns.set(sessionId, { messageSent: true })
}
/**
* Test hook: register a user turn still in the pre-send (messageSent:false)
* window — i.e. the CLI-startup window that isSessionTurnActive is blind to.
*/
export function __registerPendingUserTurnForTests(sessionId: string): void {
activeUserTurns.set(sessionId, { messageSent: false })
}
/** Test hook: arm the prewarm idle timer for a session, as markPrewarmed does. */
export function __markPrewarmedForTests(sessionId: string): void {
markPrewarmed(sessionId)
}