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.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-16 18:21:33 +08:00
parent 2d7d11d822
commit 3fb3d24911
10 changed files with 731 additions and 239 deletions

View File

@ -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}',

View File

@ -912,6 +912,14 @@ export const zh: Record<TranslationKey, string> = {
'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}',

View File

@ -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(<ActiveSession />)
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()

View File

@ -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 (
<div
data-testid="background-agent-panel"
className={
compact
? 'mx-auto w-full max-w-[860px] px-4 py-2'
: 'mx-auto w-full max-w-[860px] px-8 py-2.5'
}
>
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<div className="flex min-w-0 items-center gap-2 border-b border-[var(--color-border)] px-3 py-2">
<Bot size={15} strokeWidth={2.25} className="shrink-0 text-[var(--color-text-secondary)]" aria-hidden="true" />
<span className="shrink-0 text-[13px] font-medium text-[var(--color-text-primary)]">
{t('chat.backgroundAgents.title')}
</span>
<span className="truncate text-[12px] text-[var(--color-text-tertiary)]">
{t('chat.backgroundAgents.count', { count: tasks.length })}
</span>
</div>
<div className="divide-y divide-[var(--color-border)]">
{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 (
<div key={task.taskId} className="flex min-w-0 items-start gap-2 px-3 py-2">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
{isRunning ? (
<LoaderCircle size={15} strokeWidth={2.25} className="animate-spin text-[var(--color-accent)]" aria-hidden="true" />
) : isFailed ? (
<XCircle size={15} strokeWidth={2.25} className="text-[var(--color-error)]" aria-hidden="true" />
) : (
<CheckCircle2 size={15} strokeWidth={2.25} className="text-[var(--color-success)]" aria-hidden="true" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-[12px] font-medium text-[var(--color-text-primary)]">
{task.taskType || t('chat.backgroundAgents.agent')}
</span>
<span className="shrink-0 text-[11px] text-[var(--color-text-tertiary)]">
{t(`chat.backgroundAgents.status.${task.status}`)}
</span>
{task.usage?.totalTokens ? (
<span className="hidden shrink-0 text-[11px] text-[var(--color-text-tertiary)] sm:inline">
{t('chat.backgroundAgents.tokens', { count: task.usage.totalTokens.toLocaleString() })}
</span>
) : null}
{duration ? (
<span className="hidden shrink-0 text-[11px] text-[var(--color-text-tertiary)] sm:inline">
{duration}
</span>
) : null}
</div>
<div className="mt-0.5 truncate text-[12px] text-[var(--color-text-secondary)]">
{detail}
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
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}
/>
)}
<BackgroundAgentTasksPanel
tasks={backgroundAgentTasks}
compact={showWorkspacePanel || isMobileLayout}
/>
<MessageList compact={showWorkspacePanel} />
</>

View File

@ -150,6 +150,7 @@ function makeSession(overrides: Partial<PerSessionState> = {}): 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()

View File

@ -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<string, AgentTaskNotification>
backgroundAgentTasks?: Record<string, BackgroundAgentTask>
activeGoal?: ActiveGoalState | null
elapsedTimer: ReturnType<typeof setInterval> | 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<ChatStore>((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<ChatStore>((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<string, unknown>
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<string, unknown>, ...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<string, unknown>
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<BackgroundAgentTask> & Pick<BackgroundAgentTask, 'taskId' | 'status'> | null {
if (!data || typeof data !== 'object') return null
const record = data as Record<string, unknown>
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<string, BackgroundAgentTask>,
event: Partial<BackgroundAgentTask> & Pick<BackgroundAgentTask, 'taskId' | 'status'>,
now: number,
): Record<string, BackgroundAgentTask> {
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,

View File

@ -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 = {

View File

@ -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'

View File

@ -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<WebSocketData>({
port,
hostname: host,
idleTimeout: 60,
let server: ReturnType<typeof Bun.serve<WebSocketData>>
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<WebSocketData>({
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)
}
}
}

View File

@ -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 [{