mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
635a966c3e
This folds together the desktop-side fixes needed before broader rollout. Session resume no longer deadlocks waiting on init, Mermaid and inline image output render inside chat, task and sub-agent state stay visible during execution, local build/release paths are safer, and Feishu/Telegram now expose lightweight mobile commands (/help, /status, /clear) without adding a new adapter-specific protocol. Constraint: Desktop releases must publish updater artifacts from non-draft GitHub releases Constraint: IM commands need short, phone-friendly responses and low operational complexity Rejected: Add a dedicated IM command API surface | re-used existing slash commands and session/task REST endpoints to keep adapters thin Rejected: Wait for task_update push events in WebUI | added low-risk polling because the current frontend ignores that event path Confidence: medium Scope-risk: broad Reversibility: clean Directive: Keep IM command replies terse and mobile-first, and merge local fallback slash commands when server-provided lists are partial Tested: cd desktop && bun x vitest run src/components/chat/MermaidRenderer.test.tsx src/components/markdown/MarkdownRenderer.test.tsx Tested: cd desktop && bun x vitest run src/components/chat/composerUtils.test.ts src/pages/ActiveSession.test.tsx src/stores/chatStore.test.ts Tested: cd desktop && bun run lint Tested: bun test src/server/__tests__/conversations.test.ts --test-name-pattern "SDK init arrives only after the first user turn" --timeout 60000 Tested: cd adapters && bun test common/ feishu/ telegram/ Tested: cd adapters && bunx tsc --noEmit Not-tested: Full GitHub Actions release run on all three desktop platforms Not-tested: Local DMG packaging end-to-end on Apple Silicon Not-tested: Real Feishu/Telegram device sessions against a live adapter process
724 lines
24 KiB
TypeScript
724 lines
24 KiB
TypeScript
/**
|
|
* WebSocket connection handler
|
|
*
|
|
* 管理 WebSocket 连接生命周期,处理消息路由。
|
|
* 用户消息通过 CLI 子进程(stream-json 模式)处理,
|
|
* CLI stdout 消息被转换为 ServerMessage 并转发到 WebSocket。
|
|
*/
|
|
|
|
import type { ServerWebSocket } from 'bun'
|
|
import type { ClientMessage, ServerMessage } from './events.js'
|
|
import * as os from 'node:os'
|
|
import {
|
|
ConversationStartupError,
|
|
conversationService,
|
|
} from '../services/conversationService.js'
|
|
import { sessionService } from '../services/sessionService.js'
|
|
import { SettingsService } from '../services/settingsService.js'
|
|
import { ProviderService } from '../services/providerService.js'
|
|
import { deriveTitle, generateTitle, saveAiTitle } from '../services/titleService.js'
|
|
|
|
const settingsService = new SettingsService()
|
|
const providerService = new ProviderService()
|
|
|
|
/**
|
|
* Cache slash commands from CLI init messages, keyed by sessionId.
|
|
*/
|
|
const sessionSlashCommands = new Map<string, Array<{ name: string; description: 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
|
|
allUserMessages: string[]
|
|
}>()
|
|
|
|
export function getSlashCommands(sessionId: string): Array<{ name: string; description: string }> {
|
|
return sessionSlashCommands.get(sessionId) || []
|
|
}
|
|
|
|
export type WebSocketData = {
|
|
sessionId: string
|
|
connectedAt: number
|
|
channel: 'client' | 'sdk'
|
|
sdkToken: string | null
|
|
serverPort: number
|
|
serverHost: string
|
|
}
|
|
|
|
// Active WebSocket sessions
|
|
const activeSessions = new Map<string, ServerWebSocket<WebSocketData>>()
|
|
|
|
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}`)
|
|
|
|
activeSessions.set(sessionId, ws)
|
|
|
|
const msg: ServerMessage = { type: 'connected', sessionId }
|
|
ws.send(JSON.stringify(msg))
|
|
},
|
|
|
|
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) => {
|
|
console.error(`[WS] Unhandled error in handleUserMessage:`, err)
|
|
})
|
|
break
|
|
|
|
case 'permission_response':
|
|
handlePermissionResponse(ws, message)
|
|
break
|
|
|
|
case 'set_permission_mode':
|
|
handleSetPermissionMode(ws, message)
|
|
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})`)
|
|
activeSessions.delete(sessionId)
|
|
cleanupStreamState(sessionId)
|
|
sessionSlashCommands.delete(sessionId)
|
|
sessionTitleState.delete(sessionId)
|
|
|
|
// NOTE: Do NOT stop CLI subprocess on WS disconnect.
|
|
// The CLI process should stay alive so reconnecting reuses it.
|
|
// This prevents "Session ID already in use" errors from stale locks.
|
|
},
|
|
|
|
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
|
|
let workDir = os.homedir()
|
|
|
|
// Send thinking status
|
|
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' })
|
|
|
|
// 启动 CLI 子进程(如果还没有)
|
|
if (!conversationService.hasSession(sessionId)) {
|
|
try {
|
|
// Resolve the session's actual working directory
|
|
try {
|
|
const resolved = await sessionService.getSessionWorkDir(sessionId)
|
|
if (resolved) workDir = resolved
|
|
} catch {
|
|
// fallback to cwd if session file not found
|
|
}
|
|
const runtimeSettings = await getRuntimeSettings()
|
|
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
|
|
}
|
|
}
|
|
|
|
// Track user message for title generation
|
|
let titleState = sessionTitleState.get(sessionId)
|
|
if (!titleState) {
|
|
titleState = { userMessageCount: 0, hasCustomTitle: false, firstUserMessage: '', allUserMessages: [] }
|
|
sessionTitleState.set(sessionId, titleState)
|
|
}
|
|
titleState.userMessageCount++
|
|
titleState.allUserMessages.push(message.content)
|
|
if (titleState.userMessageCount === 1) {
|
|
titleState.firstUserMessage = message.content
|
|
}
|
|
|
|
// 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
|
|
|
|
conversationService.clearOutputCallbacks(sessionId)
|
|
conversationService.onOutput(sessionId, (cliMsg) => {
|
|
// Before the current turn is sent, only surface startup errors.
|
|
if (!userMessageSent) {
|
|
if (cliMsg.type === 'result' && cliMsg.is_error) {
|
|
const serverMsgs = translateCliMessage(cliMsg, sessionId)
|
|
for (const msg of serverMsgs) {
|
|
sendMessage(ws, msg)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
const serverMsgs = translateCliMessage(cliMsg, sessionId)
|
|
for (const msg of serverMsgs) {
|
|
sendMessage(ws, msg)
|
|
}
|
|
// Trigger title generation on message_complete
|
|
if (cliMsg.type === 'result') {
|
|
triggerTitleGeneration(ws, sessionId)
|
|
}
|
|
})
|
|
|
|
const sent = conversationService.sendMessage(
|
|
sessionId,
|
|
message.content,
|
|
message.attachments
|
|
)
|
|
if (!sent) {
|
|
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
|
|
}
|
|
|
|
function handlePermissionResponse(
|
|
ws: ServerWebSocket<WebSocketData>,
|
|
message: Extract<ClientMessage, { type: 'permission_response' }>
|
|
) {
|
|
const { sessionId } = ws.data
|
|
conversationService.respondToPermission(
|
|
sessionId,
|
|
message.requestId,
|
|
message.allowed,
|
|
message.rule,
|
|
)
|
|
console.log(`[WS] Permission response for ${message.requestId}: ${message.allowed}`)
|
|
}
|
|
|
|
function handleSetPermissionMode(
|
|
ws: ServerWebSocket<WebSocketData>,
|
|
message: Extract<ClientMessage, { type: 'set_permission_mode' }>
|
|
) {
|
|
const { sessionId } = ws.data
|
|
const ok = conversationService.setPermissionMode(sessionId, message.mode)
|
|
if (!ok) {
|
|
console.warn(`[WS] Ignored permission mode update for inactive session ${sessionId}`)
|
|
}
|
|
}
|
|
|
|
function handleStopGeneration(ws: ServerWebSocket<WebSocketData>) {
|
|
const { sessionId } = ws.data
|
|
console.log(`[WS] Stop generation requested for session: ${sessionId}`)
|
|
|
|
// 向 CLI 子进程发送中断信号
|
|
if (conversationService.hasSession(sessionId)) {
|
|
conversationService.sendInterrupt(sessionId)
|
|
}
|
|
|
|
sendMessage(ws, { type: 'status', state: 'idle' })
|
|
}
|
|
|
|
// ============================================================================
|
|
// Title generation
|
|
// ============================================================================
|
|
|
|
function triggerTitleGeneration(ws: ServerWebSocket<WebSocketData>, sessionId: string): void {
|
|
const state = sessionTitleState.get(sessionId)
|
|
if (!state || state.hasCustomTitle) return
|
|
|
|
const count = state.userMessageCount
|
|
|
|
// Generate on count 1 (first response) and count 3 (with more context)
|
|
if (count !== 1 && count !== 3) return
|
|
|
|
const text = count === 1
|
|
? state.firstUserMessage
|
|
: state.allUserMessages.join('\n')
|
|
|
|
// Fire-and-forget: derive quick title, then upgrade with AI
|
|
void (async () => {
|
|
try {
|
|
// Stage 1: quick placeholder (only on first message)
|
|
if (count === 1) {
|
|
const placeholder = deriveTitle(text)
|
|
if (placeholder) {
|
|
await saveAiTitle(sessionId, placeholder)
|
|
sendMessage(ws, { type: 'session_title_updated', sessionId, title: placeholder })
|
|
}
|
|
}
|
|
|
|
// Stage 2: AI-generated title
|
|
const aiTitle = await generateTitle(text)
|
|
if (aiTitle) {
|
|
await saveAiTitle(sessionId, aiTitle)
|
|
sendMessage(ws, { type: 'session_title_updated', sessionId, title: aiTitle })
|
|
}
|
|
} catch (err) {
|
|
console.error(`[Title] Failed to generate title for ${sessionId}:`, err)
|
|
}
|
|
})()
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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'>
|
|
activeToolBlocks: Map<number, { toolName: string; toolUseId: string; inputJson: 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(),
|
|
}
|
|
sessionStreamStates.set(sessionId, state)
|
|
}
|
|
return state
|
|
}
|
|
|
|
/** Clean up stream state when session disconnects */
|
|
function cleanupStreamState(sessionId: string) {
|
|
sessionStreamStates.delete(sessionId)
|
|
}
|
|
|
|
function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
|
|
const streamState = getStreamState(sessionId)
|
|
switch (cliMsg.type) {
|
|
case 'assistant': {
|
|
if (cliMsg.error) {
|
|
return [{
|
|
type: 'error',
|
|
message: cliMsg.message?.content?.[0]?.text || cliMsg.error,
|
|
code: cliMsg.error,
|
|
}]
|
|
}
|
|
|
|
// 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) {
|
|
// Everything was already sent via stream_event — skip all blocks
|
|
} 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') {
|
|
messages.push({
|
|
type: 'tool_use_complete',
|
|
toolName: block.name,
|
|
toolUseId: block.id,
|
|
input: block.input,
|
|
parentToolUseId:
|
|
typeof cliMsg.parent_tool_use_id === 'string'
|
|
? cliMsg.parent_tool_use_id
|
|
: undefined,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reset flag for next turn
|
|
streamState.hasReceivedStreamEvents = false
|
|
return messages
|
|
}
|
|
return []
|
|
}
|
|
|
|
case 'user': {
|
|
// Bug #1: 处理 tool_result 消息
|
|
// CLI 发送 type:'user' 消息,其中 content 包含 tool_result 块
|
|
const messages: ServerMessage[] = []
|
|
|
|
if (cliMsg.message?.content && Array.isArray(cliMsg.message.content)) {
|
|
for (const block of cliMsg.message.content) {
|
|
if (block.type === 'tool_result') {
|
|
messages.push({
|
|
type: 'tool_result',
|
|
toolUseId: block.tool_use_id,
|
|
content: block.content,
|
|
isError: !!block.is_error,
|
|
parentToolUseId:
|
|
typeof cliMsg.parent_tool_use_id === 'string'
|
|
? cliMsg.parent_tool_use_id
|
|
: undefined,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
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: 'streaming' }]
|
|
}
|
|
|
|
case 'content_block_start': {
|
|
const contentBlock = event.content_block
|
|
if (!contentBlock) return []
|
|
|
|
const index = event.index ?? 0
|
|
streamState.activeBlockTypes.set(index, contentBlock.type === 'tool_use' ? 'tool_use' : 'text')
|
|
|
|
if (contentBlock.type === 'tool_use') {
|
|
// Track tool info so content_block_stop can emit complete data
|
|
streamState.activeToolBlocks.set(index, {
|
|
toolName: contentBlock.name || '',
|
|
toolUseId: contentBlock.id || '',
|
|
inputJson: '',
|
|
})
|
|
return [{
|
|
type: 'content_start',
|
|
blockType: 'tool_use',
|
|
toolName: contentBlock.name,
|
|
toolUseId: contentBlock.id,
|
|
parentToolUseId:
|
|
typeof cliMsg.parent_tool_use_id === 'string'
|
|
? cliMsg.parent_tool_use_id
|
|
: undefined,
|
|
}]
|
|
}
|
|
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) {
|
|
let parsedInput = null
|
|
try { parsedInput = JSON.parse(toolBlock.inputJson) } catch {}
|
|
return [{
|
|
type: 'tool_use_complete',
|
|
toolName: toolBlock.toolName,
|
|
toolUseId: toolBlock.toolUseId,
|
|
input: parsedInput,
|
|
parentToolUseId:
|
|
typeof cliMsg.parent_tool_use_id === 'string'
|
|
? cliMsg.parent_tool_use_id
|
|
: undefined,
|
|
}]
|
|
}
|
|
}
|
|
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',
|
|
input: cliMsg.request.input || {},
|
|
description: cliMsg.request.description,
|
|
}]
|
|
}
|
|
return []
|
|
}
|
|
|
|
case 'control_response':
|
|
return []
|
|
|
|
case 'result': {
|
|
// 对话结果(成功或错误)
|
|
const usage = {
|
|
input_tokens: cliMsg.usage?.input_tokens || 0,
|
|
output_tokens: cliMsg.usage?.output_tokens || 0,
|
|
}
|
|
|
|
if (cliMsg.is_error) {
|
|
const resultMessage =
|
|
(typeof cliMsg.result === 'string' && cliMsg.result) ||
|
|
(Array.isArray(cliMsg.errors) && cliMsg.errors.length > 0
|
|
? cliMsg.errors.join('\n')
|
|
: 'Unknown error')
|
|
// 错误和完成消息都发送
|
|
return [
|
|
{
|
|
type: 'error',
|
|
message: resultMessage,
|
|
code: 'CLI_ERROR',
|
|
},
|
|
{ type: 'message_complete', usage },
|
|
]
|
|
}
|
|
|
|
return [{ type: 'message_complete', usage }]
|
|
}
|
|
|
|
case 'system': {
|
|
// 区分不同的 system 子类型
|
|
const subtype = cliMsg.subtype
|
|
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.
|
|
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 || ''),
|
|
})))
|
|
}
|
|
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 === 'hook_started' || subtype === 'hook_response') {
|
|
// Hook 执行中 — 不转发给前端
|
|
return []
|
|
}
|
|
// 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: 'status',
|
|
state: 'tool_executing',
|
|
verb: cliMsg.message || 'Task started',
|
|
}]
|
|
}
|
|
if (subtype === 'task_progress') {
|
|
return [{
|
|
type: 'status',
|
|
state: 'tool_executing',
|
|
verb: cliMsg.message || 'Task in progress',
|
|
}]
|
|
}
|
|
if (subtype === 'session_state_changed') {
|
|
return [{
|
|
type: 'system_notification',
|
|
subtype: 'session_state_changed',
|
|
message: cliMsg.message,
|
|
data: cliMsg,
|
|
}]
|
|
}
|
|
// 其他 system 消息
|
|
return []
|
|
}
|
|
|
|
default:
|
|
// 未知类型 — 调试输出但不转发
|
|
console.log(`[WS] Unknown CLI message type: ${cliMsg.type}`, JSON.stringify(cliMsg).substring(0, 200))
|
|
return []
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helpers
|
|
// ============================================================================
|
|
|
|
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 })
|
|
}
|
|
|
|
async function getRuntimeSettings(): Promise<{
|
|
permissionMode?: string
|
|
model?: string
|
|
effort?: string
|
|
}> {
|
|
const userSettings = await settingsService.getUserSettings()
|
|
const modelContext =
|
|
typeof userSettings.modelContext === 'string' && userSettings.modelContext.trim()
|
|
? userSettings.modelContext
|
|
: undefined
|
|
const effort =
|
|
typeof userSettings.effort === 'string' && userSettings.effort.trim()
|
|
? userSettings.effort
|
|
: undefined
|
|
|
|
// Check if a custom provider is active
|
|
const { activeId } = await providerService.listProviders()
|
|
|
|
let model: string | undefined
|
|
if (activeId) {
|
|
// Provider is active — only pass --model if user explicitly selected a non-default model.
|
|
// Otherwise the CLI should use ANTHROPIC_MODEL from env (set by syncToSettings).
|
|
// Default Anthropic model should be overridden by the provider's model.
|
|
const baseModel = (userSettings.model as string) || ''
|
|
if (baseModel && baseModel !== 'claude-sonnet-4-6-20250514') {
|
|
// User explicitly selected a different model — pass it through
|
|
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,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a message to a specific session's WebSocket (for use by services)
|
|
*/
|
|
export function sendToSession(sessionId: string, message: ServerMessage): boolean {
|
|
const ws = activeSessions.get(sessionId)
|
|
if (!ws) return false
|
|
ws.send(JSON.stringify(message))
|
|
return true
|
|
}
|
|
|
|
export function getActiveSessionIds(): string[] {
|
|
return Array.from(activeSessions.keys())
|
|
}
|