From 3fb3d249110fc2be1f57d46a95bfa3f1b9b1ab34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 16 May 2026 18:21:33 +0800 Subject: [PATCH] Reveal background agent progress during goal runs Desktop sessions previously only showed the final local-agent output, so long verification phases looked stalled even while the CLI was still working. The websocket bridge now forwards task start/progress events, and the desktop session state renders them as a compact background-agent panel below the active goal. Constraint: CLI already emits task lifecycle events; desktop needed to consume the existing stream instead of polling transcript files. Rejected: Wait for final task notifications only | that keeps the long-running phase invisible. Confidence: high Scope-risk: moderate Directive: Keep task_started/task_progress available as structured desktop notifications when changing CLI event translation. Tested: bun run verify (passed=8 failed=0 skipped=2) Tested: bun run check:desktop Tested: bun run check:server Tested: bun run check:native Tested: bun run check:coverage Not-tested: Post-formatting check:server rerun was intentionally stopped at user request. --- desktop/src/i18n/locales/en.ts | 8 + desktop/src/i18n/locales/zh.ts | 8 + desktop/src/pages/ActiveSession.test.tsx | 77 ++++ desktop/src/pages/ActiveSession.tsx | 103 ++++- desktop/src/stores/chatStore.test.ts | 93 ++++ desktop/src/stores/chatStore.ts | 146 +++++- desktop/src/types/chat.ts | 23 + src/server/__tests__/ws-memory-events.test.ts | 57 +++ src/server/index.ts | 419 +++++++++--------- src/server/ws/handler.ts | 36 +- 10 files changed, 731 insertions(+), 239 deletions(-) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 7dffc94a..653397a0 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -910,6 +910,14 @@ export const en = { 'chat.activeGoal.budget': 'Budget {value}', 'chat.activeGoal.continuations': 'Continuations {value}', 'chat.activeGoal.elapsed': 'Elapsed {value}', + 'chat.backgroundAgents.title': 'Background agents', + 'chat.backgroundAgents.count': '{count} active or recent', + 'chat.backgroundAgents.agent': 'agent', + 'chat.backgroundAgents.tokens': '{count} tokens', + 'chat.backgroundAgents.status.running': 'running', + 'chat.backgroundAgents.status.completed': 'completed', + 'chat.backgroundAgents.status.failed': 'failed', + 'chat.backgroundAgents.status.stopped': 'stopped', 'slash.mcp.title': 'Available MCP tools', 'slash.mcp.subtitle': 'Global and current-project MCP servers available in this chat context.', 'slash.mcp.subtitleWithProject': 'Showing global plus project MCP for: {path}', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 1baca488..8c275bee 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -912,6 +912,14 @@ export const zh: Record = { 'chat.activeGoal.budget': '预算 {value}', 'chat.activeGoal.continuations': '续作次数 {value}', 'chat.activeGoal.elapsed': '已用时 {value}', + 'chat.backgroundAgents.title': '后台 Agent', + 'chat.backgroundAgents.count': '{count} 个运行中或最近任务', + 'chat.backgroundAgents.agent': 'agent', + 'chat.backgroundAgents.tokens': '{count} tokens', + 'chat.backgroundAgents.status.running': '运行中', + 'chat.backgroundAgents.status.completed': '已完成', + 'chat.backgroundAgents.status.failed': '失败', + 'chat.backgroundAgents.status.stopped': '已停止', 'slash.mcp.title': '可用 MCP 工具', 'slash.mcp.subtitle': '展示当前聊天上下文里的全局 MCP 和当前项目 MCP。', 'slash.mcp.subtitleWithProject': '当前展示全局 MCP 以及这个项目的 MCP:{path}', diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index a22c4648..a62803a1 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -207,6 +207,83 @@ describe('ActiveSession task polling', () => { expect(panel).toHaveTextContent('续作次数 0') }) + it('shows background agent progress below the goal panel', () => { + const sessionId = 'background-agent-visible-session' + + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'Background Agent Session', + createdAt: '2026-05-07T00:00:00.000Z', + modifiedAt: '2026-05-07T00:00:00.000Z', + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: sessionId, + isLoading: false, + error: null, + }) + useTabStore.setState({ + tabs: [{ sessionId, title: 'Background Agent Session', type: 'session', status: 'running' }], + activeTabId: sessionId, + }) + useChatStore.setState({ + sessions: { + [sessionId]: { + messages: [], + activeGoal: { + action: 'created', + status: 'active', + objective: 'ship the smoke test', + updatedAt: 1, + }, + backgroundAgentTasks: { + 'agent-task-1': { + taskId: 'agent-task-1', + toolUseId: 'agent-tool-1', + status: 'running', + taskType: 'local_agent', + description: 'Verify the todo app', + summary: 'Running Playwright checks', + usage: { + totalTokens: 1200, + toolUses: 4, + durationMs: 45000, + }, + startedAt: 1, + updatedAt: 2, + }, + }, + chatState: 'tool_executing', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + render() + + const panel = screen.getByTestId('background-agent-panel') + expect(panel).toHaveTextContent('后台 Agent') + expect(panel).toHaveTextContent('运行中') + expect(panel).toHaveTextContent('local_agent') + expect(panel).toHaveTextContent('Running Playwright checks') + }) + it('refreshes CLI tasks repeatedly while a turn is active', async () => { vi.useFakeTimers() diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 41353a62..eac16e60 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -28,8 +28,8 @@ import { TerminalSettings } from './TerminalSettings' import type { SessionListItem } from '../types/session' import { useMobileViewport } from '../hooks/useMobileViewport' import { isTauriRuntime } from '../lib/desktopRuntime' -import type { ActiveGoalState } from '../types/chat' -import { Target } from 'lucide-react' +import type { ActiveGoalState, BackgroundAgentTask } from '../types/chat' +import { Bot, CheckCircle2, LoaderCircle, Target, XCircle } from 'lucide-react' const TASK_POLL_INTERVAL_MS = 1000 const WORKSPACE_RESIZE_STEP = 32 @@ -274,6 +274,92 @@ function GoalStatusPanel({ ) } +function formatBackgroundTaskDuration(durationMs?: number) { + if (typeof durationMs !== 'number' || durationMs < 0) return null + const seconds = Math.round(durationMs / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + return `${minutes}m ${seconds % 60}s` +} + +function BackgroundAgentTasksPanel({ + tasks, + compact, +}: { + tasks: BackgroundAgentTask[] + compact: boolean +}) { + const t = useTranslation() + if (tasks.length === 0) return null + + return ( +
+
+
+
+
+ {tasks.map((task) => { + const isRunning = task.status === 'running' + const isFailed = task.status === 'failed' || task.status === 'stopped' + const duration = formatBackgroundTaskDuration(task.usage?.durationMs) + const detail = task.summary || task.lastToolName || task.description || task.outputFile || task.taskId + return ( +
+ + {isRunning ? ( + +
+
+ + {task.taskType || t('chat.backgroundAgents.agent')} + + + {t(`chat.backgroundAgents.status.${task.status}`)} + + {task.usage?.totalTokens ? ( + + {t('chat.backgroundAgents.tokens', { count: task.usage.totalTokens.toLocaleString() })} + + ) : null} + {duration ? ( + + {duration} + + ) : null} +
+
+ {detail} +
+
+
+ ) + })} +
+
+
+ ) +} + export function ActiveSession() { const isMobileLayout = useMobileViewport() && !isTauriRuntime() const activeTabId = useTabStore((s) => s.activeTabId) @@ -287,6 +373,15 @@ export function ActiveSession() { const hasIncompleteTasks = useCLITaskStore((s) => s.tasks.some((task) => task.status !== 'completed')) const chatState = sessionState?.chatState ?? 'idle' const activeGoal = sessionState?.activeGoal ?? null + const backgroundAgentTasks = useMemo( + () => Object.values(sessionState?.backgroundAgentTasks ?? {}) + .sort((a, b) => { + if (a.status === 'running' && b.status !== 'running') return -1 + if (a.status !== 'running' && b.status === 'running') return 1 + return b.updatedAt - a.updatedAt + }), + [sessionState?.backgroundAgentTasks], + ) const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 } const session = sessions.find((s) => s.id === activeTabId) @@ -503,6 +598,10 @@ export function ActiveSession() { compact={showWorkspacePanel || isMobileLayout} /> )} + diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index d498525f..80046372 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -150,6 +150,7 @@ function makeSession(overrides: Partial = {}): PerSessionState statusVerb: '', slashCommands: [], agentTaskNotifications: {}, + backgroundAgentTasks: {}, elapsedTimer: null, ...overrides, } @@ -1127,6 +1128,98 @@ describe('chatStore history mapping', () => { }) }) + it('tracks background agent task lifecycle events for desktop visibility', () => { + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ + chatState: 'tool_executing', + }), + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'system_notification', + subtype: 'task_started', + data: { + task_id: 'agent-task-1', + tool_use_id: 'agent-tool-1', + description: 'Verify the todo app', + task_type: 'local_agent', + prompt: 'Run E2E verification', + }, + }) + + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['agent-task-1']).toMatchObject({ + taskId: 'agent-task-1', + toolUseId: 'agent-tool-1', + status: 'running', + description: 'Verify the todo app', + taskType: 'local_agent', + prompt: 'Run E2E verification', + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'system_notification', + subtype: 'task_progress', + data: { + task_id: 'agent-task-1', + tool_use_id: 'agent-tool-1', + description: 'Verify the todo app', + summary: 'Running Playwright checks', + last_tool_name: 'Bash', + usage: { + total_tokens: 1200, + tool_uses: 4, + duration_ms: 45000, + }, + }, + }) + + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['agent-task-1']).toMatchObject({ + status: 'running', + summary: 'Running Playwright checks', + lastToolName: 'Bash', + usage: { + totalTokens: 1200, + toolUses: 4, + durationMs: 45000, + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'system_notification', + subtype: 'task_notification', + data: { + task_id: 'agent-task-1', + tool_use_id: 'agent-tool-1', + status: 'completed', + summary: 'Found and fixed localStorage corruption.', + output_file: '/tmp/agent-output.txt', + usage: { + total_tokens: 2400, + tool_uses: 9, + duration_ms: 120000, + }, + }, + }) + + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['agent-task-1']).toMatchObject({ + status: 'completed', + summary: 'Found and fixed localStorage corruption.', + outputFile: '/tmp/agent-output.txt', + usage: { + totalTokens: 2400, + toolUses: 9, + durationMs: 120000, + }, + }) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.agentTaskNotifications['agent-tool-1']).toMatchObject({ + status: 'completed', + summary: 'Found and fixed localStorage corruption.', + outputFile: '/tmp/agent-output.txt', + }) + }) + it('clears local desktop chat state when the server confirms /clear', () => { vi.useFakeTimers() diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 19ac96c3..153e661e 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -17,6 +17,8 @@ import type { ActiveGoalState, AgentTaskNotification, AttachmentRef, + BackgroundAgentTask, + BackgroundAgentTaskUsage, ChatState, ComputerUsePermissionRequest, ComputerUsePermissionResponse, @@ -55,6 +57,7 @@ export type PerSessionState = { statusVerb: string slashCommands: Array<{ name: string; description: string; argumentHint?: string }> agentTaskNotifications: Record + backgroundAgentTasks?: Record activeGoal?: ActiveGoalState | null elapsedTimer: ReturnType | null composerPrefill?: { @@ -80,6 +83,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, + backgroundAgentTasks: {}, activeGoal: null, elapsedTimer: null, composerPrefill: null, @@ -929,6 +933,8 @@ export const useChatStore = create((set, get) => ({ tokenUsage: { input_tokens: 0, output_tokens: 0 }, slashCommands: [], activeGoal: null, + backgroundAgentTasks: {}, + agentTaskNotifications: {}, })) clearPendingDelta(sessionId) clearPendingTaskToolUseIds(sessionId) @@ -988,38 +994,52 @@ export const useChatStore = create((set, get) => ({ })) } } + if ((msg.subtype === 'task_started' || msg.subtype === 'task_progress') && msg.data && typeof msg.data === 'object') { + const taskEvent = normalizeBackgroundAgentTaskEvent(msg.data, msg.subtype) + if (taskEvent) { + const now = Date.now() + update((session) => ({ + backgroundAgentTasks: upsertBackgroundAgentTask( + session.backgroundAgentTasks ?? {}, + taskEvent, + now, + ), + })) + } + } if (msg.subtype === 'task_notification' && msg.data && typeof msg.data === 'object') { const data = msg.data as Record + const taskEvent = normalizeBackgroundAgentTaskEvent(data, 'task_notification') const toolUseId = typeof data.tool_use_id === 'string' && data.tool_use_id.trim() ? data.tool_use_id : null const taskStatus = data.status - if ( - toolUseId && - (taskStatus === 'completed' || - taskStatus === 'failed' || - taskStatus === 'stopped') - ) { + if (taskEvent) { + const now = Date.now() update((session) => ({ + backgroundAgentTasks: upsertBackgroundAgentTask( + session.backgroundAgentTasks ?? {}, + taskEvent, + now, + ), agentTaskNotifications: { ...session.agentTaskNotifications, - [toolUseId]: { - taskId: - typeof data.task_id === 'string' && data.task_id.trim() - ? data.task_id - : toolUseId, - toolUseId, - status: taskStatus, - summary: - typeof data.summary === 'string' && data.summary.trim() - ? data.summary - : undefined, - outputFile: - typeof data.output_file === 'string' && data.output_file.trim() - ? data.output_file - : undefined, - }, + ...(toolUseId && + (taskStatus === 'completed' || + taskStatus === 'failed' || + taskStatus === 'stopped') + ? { + [toolUseId]: { + taskId: taskEvent.taskId, + toolUseId, + status: taskStatus, + summary: taskEvent.summary, + outputFile: taskEvent.outputFile, + usage: taskEvent.usage, + }, + } + : {}), }, })) } @@ -1109,6 +1129,88 @@ function readXmlTag(xml: string, tag: string): string | undefined { return match?.[1] ? decodeXmlText(match[1].trim()) : undefined } +function readNonEmptyString(record: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key] + if (typeof value === 'string' && value.trim()) return value.trim() + } + return undefined +} + +function normalizeBackgroundTaskUsage(value: unknown): BackgroundAgentTaskUsage | undefined { + if (!value || typeof value !== 'object') return undefined + const record = value as Record + const usage: BackgroundAgentTaskUsage = {} + const totalTokens = record.total_tokens ?? record.totalTokens + const toolUses = record.tool_uses ?? record.toolUses + const durationMs = record.duration_ms ?? record.durationMs + if (typeof totalTokens === 'number') usage.totalTokens = totalTokens + if (typeof toolUses === 'number') usage.toolUses = toolUses + if (typeof durationMs === 'number') usage.durationMs = durationMs + return Object.keys(usage).length > 0 ? usage : undefined +} + +function normalizeBackgroundAgentTaskEvent( + data: unknown, + subtype: string, +): Partial & Pick | null { + if (!data || typeof data !== 'object') return null + const record = data as Record + const taskId = readNonEmptyString(record, 'task_id', 'taskId') + const toolUseId = readNonEmptyString(record, 'tool_use_id', 'toolUseId') + const id = taskId ?? toolUseId + if (!id) return null + + const rawStatus = readNonEmptyString(record, 'status') + const status = rawStatus === 'completed' || rawStatus === 'failed' || rawStatus === 'stopped' + ? rawStatus + : rawStatus === 'killed' + ? 'stopped' + : subtype === 'task_notification' + ? 'completed' + : 'running' + + return { + taskId: id, + toolUseId, + status, + description: readNonEmptyString(record, 'description', 'message', 'title'), + taskType: readNonEmptyString(record, 'task_type', 'taskType'), + workflowName: readNonEmptyString(record, 'workflow_name', 'workflowName'), + prompt: readNonEmptyString(record, 'prompt'), + summary: readNonEmptyString(record, 'summary'), + lastToolName: readNonEmptyString(record, 'last_tool_name', 'lastToolName'), + outputFile: readNonEmptyString(record, 'output_file', 'outputFile'), + usage: normalizeBackgroundTaskUsage(record.usage), + } +} + +function upsertBackgroundAgentTask( + current: Record, + event: Partial & Pick, + now: number, +): Record { + const existing = current[event.taskId] + return { + ...current, + [event.taskId]: { + taskId: event.taskId, + toolUseId: event.toolUseId ?? existing?.toolUseId, + status: event.status, + description: event.description ?? existing?.description, + taskType: event.taskType ?? existing?.taskType, + workflowName: event.workflowName ?? existing?.workflowName, + prompt: event.prompt ?? existing?.prompt, + summary: event.summary ?? existing?.summary, + lastToolName: event.lastToolName ?? existing?.lastToolName, + outputFile: event.outputFile ?? existing?.outputFile, + usage: event.usage ?? existing?.usage, + startedAt: existing?.startedAt ?? now, + updatedAt: now, + }, + } +} + function normalizeGoalEventData( data: unknown, fallbackMessage?: string, diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index 907a08ac..947503e8 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -156,6 +156,29 @@ export type AgentTaskNotification = { status: 'completed' | 'failed' | 'stopped' summary?: string outputFile?: string + usage?: BackgroundAgentTaskUsage +} + +export type BackgroundAgentTaskUsage = { + totalTokens?: number + toolUses?: number + durationMs?: number +} + +export type BackgroundAgentTask = { + taskId: string + toolUseId?: string + status: 'running' | 'completed' | 'failed' | 'stopped' + description?: string + taskType?: string + workflowName?: string + prompt?: string + summary?: string + lastToolName?: string + outputFile?: string + usage?: BackgroundAgentTaskUsage + startedAt: number + updatedAt: number } export type MemoryEventFile = { diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts index 10b4e18a..4941182d 100644 --- a/src/server/__tests__/ws-memory-events.test.ts +++ b/src/server/__tests__/ws-memory-events.test.ts @@ -39,6 +39,63 @@ describe('WebSocket memory events', () => { }) }) +describe('WebSocket background task events', () => { + it('forwards task start and progress as structured desktop notifications', () => { + const started = { + type: 'system', + subtype: 'task_started', + task_id: 'agent-task-1', + tool_use_id: 'agent-tool-1', + description: 'Verify the todo app', + task_type: 'local_agent', + prompt: 'Run E2E checks', + } + + expect(translateCliMessage(started, 'session-1')).toEqual([ + { + type: 'system_notification', + subtype: 'task_started', + message: 'Verify the todo app', + data: started, + }, + { + type: 'status', + state: 'tool_executing', + verb: 'Verify the todo app', + }, + ]) + + const progress = { + type: 'system', + subtype: 'task_progress', + task_id: 'agent-task-1', + tool_use_id: 'agent-tool-1', + description: 'Verify the todo app', + summary: 'Running Playwright checks', + last_tool_name: 'Bash', + usage: { + total_tokens: 1200, + tool_uses: 4, + duration_ms: 45000, + }, + } + + expect(translateCliMessage(progress, 'session-1')).toEqual([ + { + type: 'system_notification', + subtype: 'task_progress', + message: 'Running Playwright checks', + data: progress, + }, + { + type: 'status', + state: 'tool_executing', + verb: 'Running Playwright checks', + }, + ]) + }) +}) + describe('WebSocket goal command events', () => { const goalStatusOutput = 'Goal set: ship the smoke test' diff --git a/src/server/index.ts b/src/server/index.ts index 1f560460..f9d7dadb 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -137,233 +137,242 @@ export function startServer(port = PORT, host = HOST) { process.env.SERVER_AUTH_REQUIRED === '1' const h5AccessService = new H5AccessService() - const server = Bun.serve({ - port, - hostname: host, - idleTimeout: 60, + let server: ReturnType> - async fetch(req, server) { - await ensurePersistentStorageUpgraded() - const url = new URL(req.url) - const origin = req.headers.get('Origin') - const clientAddress = server.requestIP(req)?.address ?? null - const h5RequestContext = { clientAddress } - const h5Settings = await h5AccessService.getSettings() - const h5PublicOrigin = originFromUrl(h5Settings.publicBaseUrl) - const cors = await resolveCors(origin, url.origin, { - h5Enabled: h5Settings.enabled, - isOriginAllowed: async (candidateOrigin) => - candidateOrigin === h5PublicOrigin || - await h5AccessService.isOriginAllowed(candidateOrigin), - }) - const authRequired = shouldRequireH5Token({ - request: req, - url, - h5Enabled: h5Settings.enabled, - context: h5RequestContext, - }) - const h5AccessDisabledBlocked = shouldBlockDisabledH5Access({ - request: req, - url, - h5Enabled: h5Settings.enabled, - explicitAuthRequired: forceAuth, - context: h5RequestContext, - }) - const h5AccessControlBlocked = isH5AccessControlRequest(req, url, h5RequestContext) + try { + server = Bun.serve({ + port, + hostname: host, + idleTimeout: 60, - if (h5AccessControlBlocked) { - return h5AccessControlRejectedResponse() - } - - if (h5AccessDisabledBlocked) { - return h5AccessDisabledResponse() - } - - // Handle CORS preflight - if (req.method === 'OPTIONS') { - if (cors.rejected) { - return corsRejectedResponse(cors) - } - return new Response(null, { status: 204, headers: cors.headers }) - } - - // WebSocket upgrade - if (url.pathname.startsWith('/ws/')) { - if (cors.rejected) { - return corsRejectedResponse(cors) - } - - // Enforce authentication when required - if (authRequired) { - const authError = await requireH5Token(req, url.searchParams.get('token')) - if (authError) { - return withCors(authError, cors) - } - } else if (forceAuth) { - const authError = await requireAuth(req, url.searchParams.get('token')) - if (authError) { - return withCors(authError, cors) - } - } - - // Validate session ID format - const sessionId = url.pathname.split('/').pop() || '' - if (!sessionId || !/^[0-9a-zA-Z_-]{1,64}$/.test(sessionId)) { - return new Response('Invalid session ID', { status: 400 }) - } - const upgraded = server.upgrade(req, { - data: { - sessionId, - connectedAt: Date.now(), - channel: 'client', - sdkToken: null, - serverPort: port, - serverHost: localConnectHost, - }, + async fetch(req, server) { + await ensurePersistentStorageUpgraded() + const url = new URL(req.url) + const origin = req.headers.get('Origin') + const clientAddress = server.requestIP(req)?.address ?? null + const h5RequestContext = { clientAddress } + const h5Settings = await h5AccessService.getSettings() + const h5PublicOrigin = originFromUrl(h5Settings.publicBaseUrl) + const cors = await resolveCors(origin, url.origin, { + h5Enabled: h5Settings.enabled, + isOriginAllowed: async (candidateOrigin) => + candidateOrigin === h5PublicOrigin || + await h5AccessService.isOriginAllowed(candidateOrigin), }) - if (upgraded) return undefined - return new Response('WebSocket upgrade failed', { status: 400 }) - } + const authRequired = shouldRequireH5Token({ + request: req, + url, + h5Enabled: h5Settings.enabled, + context: h5RequestContext, + }) + const h5AccessDisabledBlocked = shouldBlockDisabledH5Access({ + request: req, + url, + h5Enabled: h5Settings.enabled, + explicitAuthRequired: forceAuth, + context: h5RequestContext, + }) + const h5AccessControlBlocked = isH5AccessControlRequest(req, url, h5RequestContext) - // Internal SDK WebSocket used by the spawned Claude CLI. - if (url.pathname.startsWith('/sdk/')) { - if (classifyH5Request(req, url, h5RequestContext) !== 'internal-sdk') { + if (h5AccessControlBlocked) { return h5AccessControlRejectedResponse() } - if (cors.rejected) { - return corsRejectedResponse(cors) + if (h5AccessDisabledBlocked) { + return h5AccessDisabledResponse() } - if (forceAuth) { - const authError = await requireAuth(req, url.searchParams.get('token')) - if (authError) { - return withCors(authError, cors) + // Handle CORS preflight + if (req.method === 'OPTIONS') { + if (cors.rejected) { + return corsRejectedResponse(cors) } + return new Response(null, { status: 204, headers: cors.headers }) } - const sessionId = url.pathname.split('/').pop() || '' - if (!sessionId || !/^[0-9a-zA-Z_-]{1,64}$/.test(sessionId)) { - return new Response('Invalid session ID', { status: 400 }) - } - const upgraded = server.upgrade(req, { - data: { - sessionId, - connectedAt: Date.now(), - channel: 'sdk', - sdkToken: url.searchParams.get('token'), - serverPort: port, - serverHost: localConnectHost, - }, - }) - if (upgraded) return undefined - return new Response('WebSocket upgrade failed', { status: 400 }) - } - - if (url.pathname === '/callback') { - return handleHahaOAuthCallback(url) - } - - if (url.pathname === '/callback/openai') { - return handleHahaOpenAIOAuthCallback(url) - } - - // REST API - if (url.pathname.startsWith('/api/')) { - if (cors.rejected) { - return corsRejectedResponse(cors) - } - - // Enforce authentication when required - if (authRequired) { - const authError = await requireH5Token(req) - if (authError) { - return withCors(authError, cors) + // WebSocket upgrade + if (url.pathname.startsWith('/ws/')) { + if (cors.rejected) { + return corsRejectedResponse(cors) } - } else if (forceAuth) { - const authError = await requireAuth(req) - if (authError) { - return withCors(authError, cors) - } - } - try { - const response = await handleApiRequest(req, url) - return withCors(response, cors) - } catch (error) { - void diagnosticsService.recordEvent({ - type: 'api_request_failed', - severity: 'error', - summary: error instanceof Error ? error.message : String(error), - details: { path: url.pathname, method: req.method, error }, + // Enforce authentication when required + if (authRequired) { + const authError = await requireH5Token(req, url.searchParams.get('token')) + if (authError) { + return withCors(authError, cors) + } + } else if (forceAuth) { + const authError = await requireAuth(req, url.searchParams.get('token')) + if (authError) { + return withCors(authError, cors) + } + } + + // Validate session ID format + const sessionId = url.pathname.split('/').pop() || '' + if (!sessionId || !/^[0-9a-zA-Z_-]{1,64}$/.test(sessionId)) { + return new Response('Invalid session ID', { status: 400 }) + } + const upgraded = server.upgrade(req, { + data: { + sessionId, + connectedAt: Date.now(), + channel: 'client', + sdkToken: null, + serverPort: port, + serverHost: localConnectHost, + }, }) - console.error('[Server] API error:', error) - return withCors(Response.json( - { error: 'Internal server error' }, - { status: 500 }, - ), cors) - } - } - - // Proxy — protocol-translating reverse proxy for OpenAI-compatible APIs - if (url.pathname.startsWith('/proxy/')) { - if (cors.rejected) { - return corsRejectedResponse(cors) + if (upgraded) return undefined + return new Response('WebSocket upgrade failed', { status: 400 }) } - if (authRequired) { - const authError = await requireH5Token(req) - if (authError) { - return withCors(authError, cors) + // Internal SDK WebSocket used by the spawned Claude CLI. + if (url.pathname.startsWith('/sdk/')) { + if (classifyH5Request(req, url, h5RequestContext) !== 'internal-sdk') { + return h5AccessControlRejectedResponse() } - } else if (forceAuth) { - const authError = await requireAuth(req) - if (authError) { - return withCors(authError, cors) + + if (cors.rejected) { + return corsRejectedResponse(cors) } - } - try { - const response = await handleProxyRequest(req, url) - return withCors(response, cors) - } catch (error) { - void diagnosticsService.recordEvent({ - type: 'proxy_request_failed', - severity: 'error', - summary: error instanceof Error ? error.message : String(error), - details: { path: url.pathname, method: req.method, error }, + + if (forceAuth) { + const authError = await requireAuth(req, url.searchParams.get('token')) + if (authError) { + return withCors(authError, cors) + } + } + + const sessionId = url.pathname.split('/').pop() || '' + if (!sessionId || !/^[0-9a-zA-Z_-]{1,64}$/.test(sessionId)) { + return new Response('Invalid session ID', { status: 400 }) + } + const upgraded = server.upgrade(req, { + data: { + sessionId, + connectedAt: Date.now(), + channel: 'sdk', + sdkToken: url.searchParams.get('token'), + serverPort: port, + serverHost: localConnectHost, + }, }) - console.error('[Server] Proxy error:', error) - return withCors(Response.json( - { type: 'error', error: { type: 'api_error', message: 'Internal proxy error' } }, - { status: 500 }, - ), cors) - } - } - - // Health check - if (url.pathname === '/health') { - if (cors.rejected) { - return corsRejectedResponse(cors) + if (upgraded) return undefined + return new Response('WebSocket upgrade failed', { status: 400 }) } - return Response.json( - { status: 'ok', timestamp: new Date().toISOString() }, - { headers: cors.headers }, - ) - } + if (url.pathname === '/callback') { + return handleHahaOAuthCallback(url) + } - // Static H5 shell/assets are non-secret bootstrap content and must load - // before the browser can read the QR token; API/proxy/ws stay protected above. - const staticResponse = await handleStaticH5Request(req, url) - if (staticResponse) { - return staticResponse - } + if (url.pathname === '/callback/openai') { + return handleHahaOpenAIOAuthCallback(url) + } - return new Response('Not Found', { status: 404 }) - }, + // REST API + if (url.pathname.startsWith('/api/')) { + if (cors.rejected) { + return corsRejectedResponse(cors) + } - websocket: handleWebSocket, - }) + // Enforce authentication when required + if (authRequired) { + const authError = await requireH5Token(req) + if (authError) { + return withCors(authError, cors) + } + } else if (forceAuth) { + const authError = await requireAuth(req) + if (authError) { + return withCors(authError, cors) + } + } + + try { + const response = await handleApiRequest(req, url) + return withCors(response, cors) + } catch (error) { + void diagnosticsService.recordEvent({ + type: 'api_request_failed', + severity: 'error', + summary: error instanceof Error ? error.message : String(error), + details: { path: url.pathname, method: req.method, error }, + }) + console.error('[Server] API error:', error) + return withCors(Response.json( + { error: 'Internal server error' }, + { status: 500 }, + ), cors) + } + } + + // Proxy — protocol-translating reverse proxy for OpenAI-compatible APIs + if (url.pathname.startsWith('/proxy/')) { + if (cors.rejected) { + return corsRejectedResponse(cors) + } + + if (authRequired) { + const authError = await requireH5Token(req) + if (authError) { + return withCors(authError, cors) + } + } else if (forceAuth) { + const authError = await requireAuth(req) + if (authError) { + return withCors(authError, cors) + } + } + try { + const response = await handleProxyRequest(req, url) + return withCors(response, cors) + } catch (error) { + void diagnosticsService.recordEvent({ + type: 'proxy_request_failed', + severity: 'error', + summary: error instanceof Error ? error.message : String(error), + details: { path: url.pathname, method: req.method, error }, + }) + console.error('[Server] Proxy error:', error) + return withCors(Response.json( + { type: 'error', error: { type: 'api_error', message: 'Internal proxy error' } }, + { status: 500 }, + ), cors) + } + } + + // Health check + if (url.pathname === '/health') { + if (cors.rejected) { + return corsRejectedResponse(cors) + } + + return Response.json( + { status: 'ok', timestamp: new Date().toISOString() }, + { headers: cors.headers }, + ) + } + + // Static H5 shell/assets are non-secret bootstrap content and must load + // before the browser can read the QR token; API/proxy/ws stay protected above. + const staticResponse = await handleStaticH5Request(req, url) + if (staticResponse) { + return staticResponse + } + + return new Response('Not Found', { status: 404 }) + }, + + websocket: handleWebSocket, + }) + } catch (error) { + const message = error instanceof Error && error.message + ? error.message + : `Failed to start server. Is port ${port} in use?` + throw new Error(message, { cause: error }) + } // Start watching ~/.claude/teams/ for real-time WebSocket push teamWatcher.start() @@ -373,8 +382,8 @@ export function startServer(port = PORT, host = HOST) { void ensureDesktopCliLauncherInstalled().catch((error) => { console.error( - '[desktop-cli-launcher] failed to install bundled launcher:', - error instanceof Error ? error.message : error, + '[desktop-cli-launcher] failed to install bundled launcher:', + error instanceof Error ? error.message : error, ) }) @@ -390,7 +399,7 @@ function cleanupAllSessions() { if (active.length > 0) { console.log(`[Server] Shutting down — killing ${active.length} CLI subprocess(es)`) for (const sessionId of active) { - conversationService.stopSession(sessionId) + conversationService.stopSession(sessionId) } } } diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 19cd8a24..dbcc77a9 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -1276,18 +1276,34 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa }] } if (subtype === 'task_started') { - return [{ - type: 'status', - state: 'tool_executing', - verb: cliMsg.message || '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: 'status', - state: 'tool_executing', - verb: cliMsg.message || 'Task in 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 === 'session_state_changed') { return [{