mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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:
parent
2d7d11d822
commit
3fb3d24911
@ -910,6 +910,14 @@ export const en = {
|
|||||||
'chat.activeGoal.budget': 'Budget {value}',
|
'chat.activeGoal.budget': 'Budget {value}',
|
||||||
'chat.activeGoal.continuations': 'Continuations {value}',
|
'chat.activeGoal.continuations': 'Continuations {value}',
|
||||||
'chat.activeGoal.elapsed': 'Elapsed {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.title': 'Available MCP tools',
|
||||||
'slash.mcp.subtitle': 'Global and current-project MCP servers available in this chat context.',
|
'slash.mcp.subtitle': 'Global and current-project MCP servers available in this chat context.',
|
||||||
'slash.mcp.subtitleWithProject': 'Showing global plus project MCP for: {path}',
|
'slash.mcp.subtitleWithProject': 'Showing global plus project MCP for: {path}',
|
||||||
|
|||||||
@ -912,6 +912,14 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'chat.activeGoal.budget': '预算 {value}',
|
'chat.activeGoal.budget': '预算 {value}',
|
||||||
'chat.activeGoal.continuations': '续作次数 {value}',
|
'chat.activeGoal.continuations': '续作次数 {value}',
|
||||||
'chat.activeGoal.elapsed': '已用时 {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.title': '可用 MCP 工具',
|
||||||
'slash.mcp.subtitle': '展示当前聊天上下文里的全局 MCP 和当前项目 MCP。',
|
'slash.mcp.subtitle': '展示当前聊天上下文里的全局 MCP 和当前项目 MCP。',
|
||||||
'slash.mcp.subtitleWithProject': '当前展示全局 MCP 以及这个项目的 MCP:{path}',
|
'slash.mcp.subtitleWithProject': '当前展示全局 MCP 以及这个项目的 MCP:{path}',
|
||||||
|
|||||||
@ -207,6 +207,83 @@ describe('ActiveSession task polling', () => {
|
|||||||
expect(panel).toHaveTextContent('续作次数 0')
|
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 () => {
|
it('refreshes CLI tasks repeatedly while a turn is active', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
|
|
||||||
|
|||||||
@ -28,8 +28,8 @@ import { TerminalSettings } from './TerminalSettings'
|
|||||||
import type { SessionListItem } from '../types/session'
|
import type { SessionListItem } from '../types/session'
|
||||||
import { useMobileViewport } from '../hooks/useMobileViewport'
|
import { useMobileViewport } from '../hooks/useMobileViewport'
|
||||||
import { isTauriRuntime } from '../lib/desktopRuntime'
|
import { isTauriRuntime } from '../lib/desktopRuntime'
|
||||||
import type { ActiveGoalState } from '../types/chat'
|
import type { ActiveGoalState, BackgroundAgentTask } from '../types/chat'
|
||||||
import { Target } from 'lucide-react'
|
import { Bot, CheckCircle2, LoaderCircle, Target, XCircle } from 'lucide-react'
|
||||||
|
|
||||||
const TASK_POLL_INTERVAL_MS = 1000
|
const TASK_POLL_INTERVAL_MS = 1000
|
||||||
const WORKSPACE_RESIZE_STEP = 32
|
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() {
|
export function ActiveSession() {
|
||||||
const isMobileLayout = useMobileViewport() && !isTauriRuntime()
|
const isMobileLayout = useMobileViewport() && !isTauriRuntime()
|
||||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
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 hasIncompleteTasks = useCLITaskStore((s) => s.tasks.some((task) => task.status !== 'completed'))
|
||||||
const chatState = sessionState?.chatState ?? 'idle'
|
const chatState = sessionState?.chatState ?? 'idle'
|
||||||
const activeGoal = sessionState?.activeGoal ?? null
|
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 tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
|
||||||
|
|
||||||
const session = sessions.find((s) => s.id === activeTabId)
|
const session = sessions.find((s) => s.id === activeTabId)
|
||||||
@ -503,6 +598,10 @@ export function ActiveSession() {
|
|||||||
compact={showWorkspacePanel || isMobileLayout}
|
compact={showWorkspacePanel || isMobileLayout}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<BackgroundAgentTasksPanel
|
||||||
|
tasks={backgroundAgentTasks}
|
||||||
|
compact={showWorkspacePanel || isMobileLayout}
|
||||||
|
/>
|
||||||
|
|
||||||
<MessageList compact={showWorkspacePanel} />
|
<MessageList compact={showWorkspacePanel} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -150,6 +150,7 @@ function makeSession(overrides: Partial<PerSessionState> = {}): PerSessionState
|
|||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
slashCommands: [],
|
slashCommands: [],
|
||||||
agentTaskNotifications: {},
|
agentTaskNotifications: {},
|
||||||
|
backgroundAgentTasks: {},
|
||||||
elapsedTimer: null,
|
elapsedTimer: null,
|
||||||
...overrides,
|
...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', () => {
|
it('clears local desktop chat state when the server confirms /clear', () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,8 @@ import type {
|
|||||||
ActiveGoalState,
|
ActiveGoalState,
|
||||||
AgentTaskNotification,
|
AgentTaskNotification,
|
||||||
AttachmentRef,
|
AttachmentRef,
|
||||||
|
BackgroundAgentTask,
|
||||||
|
BackgroundAgentTaskUsage,
|
||||||
ChatState,
|
ChatState,
|
||||||
ComputerUsePermissionRequest,
|
ComputerUsePermissionRequest,
|
||||||
ComputerUsePermissionResponse,
|
ComputerUsePermissionResponse,
|
||||||
@ -55,6 +57,7 @@ export type PerSessionState = {
|
|||||||
statusVerb: string
|
statusVerb: string
|
||||||
slashCommands: Array<{ name: string; description: string; argumentHint?: string }>
|
slashCommands: Array<{ name: string; description: string; argumentHint?: string }>
|
||||||
agentTaskNotifications: Record<string, AgentTaskNotification>
|
agentTaskNotifications: Record<string, AgentTaskNotification>
|
||||||
|
backgroundAgentTasks?: Record<string, BackgroundAgentTask>
|
||||||
activeGoal?: ActiveGoalState | null
|
activeGoal?: ActiveGoalState | null
|
||||||
elapsedTimer: ReturnType<typeof setInterval> | null
|
elapsedTimer: ReturnType<typeof setInterval> | null
|
||||||
composerPrefill?: {
|
composerPrefill?: {
|
||||||
@ -80,6 +83,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = {
|
|||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
slashCommands: [],
|
slashCommands: [],
|
||||||
agentTaskNotifications: {},
|
agentTaskNotifications: {},
|
||||||
|
backgroundAgentTasks: {},
|
||||||
activeGoal: null,
|
activeGoal: null,
|
||||||
elapsedTimer: null,
|
elapsedTimer: null,
|
||||||
composerPrefill: null,
|
composerPrefill: null,
|
||||||
@ -929,6 +933,8 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
slashCommands: [],
|
slashCommands: [],
|
||||||
activeGoal: null,
|
activeGoal: null,
|
||||||
|
backgroundAgentTasks: {},
|
||||||
|
agentTaskNotifications: {},
|
||||||
}))
|
}))
|
||||||
clearPendingDelta(sessionId)
|
clearPendingDelta(sessionId)
|
||||||
clearPendingTaskToolUseIds(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') {
|
if (msg.subtype === 'task_notification' && msg.data && typeof msg.data === 'object') {
|
||||||
const data = msg.data as Record<string, unknown>
|
const data = msg.data as Record<string, unknown>
|
||||||
|
const taskEvent = normalizeBackgroundAgentTaskEvent(data, 'task_notification')
|
||||||
const toolUseId =
|
const toolUseId =
|
||||||
typeof data.tool_use_id === 'string' && data.tool_use_id.trim()
|
typeof data.tool_use_id === 'string' && data.tool_use_id.trim()
|
||||||
? data.tool_use_id
|
? data.tool_use_id
|
||||||
: null
|
: null
|
||||||
const taskStatus = data.status
|
const taskStatus = data.status
|
||||||
if (
|
if (taskEvent) {
|
||||||
toolUseId &&
|
const now = Date.now()
|
||||||
(taskStatus === 'completed' ||
|
|
||||||
taskStatus === 'failed' ||
|
|
||||||
taskStatus === 'stopped')
|
|
||||||
) {
|
|
||||||
update((session) => ({
|
update((session) => ({
|
||||||
|
backgroundAgentTasks: upsertBackgroundAgentTask(
|
||||||
|
session.backgroundAgentTasks ?? {},
|
||||||
|
taskEvent,
|
||||||
|
now,
|
||||||
|
),
|
||||||
agentTaskNotifications: {
|
agentTaskNotifications: {
|
||||||
...session.agentTaskNotifications,
|
...session.agentTaskNotifications,
|
||||||
[toolUseId]: {
|
...(toolUseId &&
|
||||||
taskId:
|
(taskStatus === 'completed' ||
|
||||||
typeof data.task_id === 'string' && data.task_id.trim()
|
taskStatus === 'failed' ||
|
||||||
? data.task_id
|
taskStatus === 'stopped')
|
||||||
: toolUseId,
|
? {
|
||||||
toolUseId,
|
[toolUseId]: {
|
||||||
status: taskStatus,
|
taskId: taskEvent.taskId,
|
||||||
summary:
|
toolUseId,
|
||||||
typeof data.summary === 'string' && data.summary.trim()
|
status: taskStatus,
|
||||||
? data.summary
|
summary: taskEvent.summary,
|
||||||
: undefined,
|
outputFile: taskEvent.outputFile,
|
||||||
outputFile:
|
usage: taskEvent.usage,
|
||||||
typeof data.output_file === 'string' && data.output_file.trim()
|
},
|
||||||
? data.output_file
|
}
|
||||||
: undefined,
|
: {}),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@ -1109,6 +1129,88 @@ function readXmlTag(xml: string, tag: string): string | undefined {
|
|||||||
return match?.[1] ? decodeXmlText(match[1].trim()) : 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(
|
function normalizeGoalEventData(
|
||||||
data: unknown,
|
data: unknown,
|
||||||
fallbackMessage?: string,
|
fallbackMessage?: string,
|
||||||
|
|||||||
@ -156,6 +156,29 @@ export type AgentTaskNotification = {
|
|||||||
status: 'completed' | 'failed' | 'stopped'
|
status: 'completed' | 'failed' | 'stopped'
|
||||||
summary?: string
|
summary?: string
|
||||||
outputFile?: 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 = {
|
export type MemoryEventFile = {
|
||||||
|
|||||||
@ -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', () => {
|
describe('WebSocket goal command events', () => {
|
||||||
const goalStatusOutput = 'Goal set: ship the smoke test'
|
const goalStatusOutput = 'Goal set: ship the smoke test'
|
||||||
|
|
||||||
|
|||||||
@ -137,233 +137,242 @@ export function startServer(port = PORT, host = HOST) {
|
|||||||
process.env.SERVER_AUTH_REQUIRED === '1'
|
process.env.SERVER_AUTH_REQUIRED === '1'
|
||||||
const h5AccessService = new H5AccessService()
|
const h5AccessService = new H5AccessService()
|
||||||
|
|
||||||
const server = Bun.serve<WebSocketData>({
|
let server: ReturnType<typeof Bun.serve<WebSocketData>>
|
||||||
port,
|
|
||||||
hostname: host,
|
|
||||||
idleTimeout: 60,
|
|
||||||
|
|
||||||
async fetch(req, server) {
|
try {
|
||||||
await ensurePersistentStorageUpgraded()
|
server = Bun.serve<WebSocketData>({
|
||||||
const url = new URL(req.url)
|
port,
|
||||||
const origin = req.headers.get('Origin')
|
hostname: host,
|
||||||
const clientAddress = server.requestIP(req)?.address ?? null
|
idleTimeout: 60,
|
||||||
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)
|
|
||||||
|
|
||||||
if (h5AccessControlBlocked) {
|
async fetch(req, server) {
|
||||||
return h5AccessControlRejectedResponse()
|
await ensurePersistentStorageUpgraded()
|
||||||
}
|
const url = new URL(req.url)
|
||||||
|
const origin = req.headers.get('Origin')
|
||||||
if (h5AccessDisabledBlocked) {
|
const clientAddress = server.requestIP(req)?.address ?? null
|
||||||
return h5AccessDisabledResponse()
|
const h5RequestContext = { clientAddress }
|
||||||
}
|
const h5Settings = await h5AccessService.getSettings()
|
||||||
|
const h5PublicOrigin = originFromUrl(h5Settings.publicBaseUrl)
|
||||||
// Handle CORS preflight
|
const cors = await resolveCors(origin, url.origin, {
|
||||||
if (req.method === 'OPTIONS') {
|
h5Enabled: h5Settings.enabled,
|
||||||
if (cors.rejected) {
|
isOriginAllowed: async (candidateOrigin) =>
|
||||||
return corsRejectedResponse(cors)
|
candidateOrigin === h5PublicOrigin ||
|
||||||
}
|
await h5AccessService.isOriginAllowed(candidateOrigin),
|
||||||
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,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if (upgraded) return undefined
|
const authRequired = shouldRequireH5Token({
|
||||||
return new Response('WebSocket upgrade failed', { status: 400 })
|
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 (h5AccessControlBlocked) {
|
||||||
if (url.pathname.startsWith('/sdk/')) {
|
|
||||||
if (classifyH5Request(req, url, h5RequestContext) !== 'internal-sdk') {
|
|
||||||
return h5AccessControlRejectedResponse()
|
return h5AccessControlRejectedResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cors.rejected) {
|
if (h5AccessDisabledBlocked) {
|
||||||
return corsRejectedResponse(cors)
|
return h5AccessDisabledResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (forceAuth) {
|
// Handle CORS preflight
|
||||||
const authError = await requireAuth(req, url.searchParams.get('token'))
|
if (req.method === 'OPTIONS') {
|
||||||
if (authError) {
|
if (cors.rejected) {
|
||||||
return withCors(authError, cors)
|
return corsRejectedResponse(cors)
|
||||||
}
|
}
|
||||||
|
return new Response(null, { status: 204, headers: cors.headers })
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = url.pathname.split('/').pop() || ''
|
// WebSocket upgrade
|
||||||
if (!sessionId || !/^[0-9a-zA-Z_-]{1,64}$/.test(sessionId)) {
|
if (url.pathname.startsWith('/ws/')) {
|
||||||
return new Response('Invalid session ID', { status: 400 })
|
if (cors.rejected) {
|
||||||
}
|
return corsRejectedResponse(cors)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
} else if (forceAuth) {
|
|
||||||
const authError = await requireAuth(req)
|
|
||||||
if (authError) {
|
|
||||||
return withCors(authError, cors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// Enforce authentication when required
|
||||||
const response = await handleApiRequest(req, url)
|
if (authRequired) {
|
||||||
return withCors(response, cors)
|
const authError = await requireH5Token(req, url.searchParams.get('token'))
|
||||||
} catch (error) {
|
if (authError) {
|
||||||
void diagnosticsService.recordEvent({
|
return withCors(authError, cors)
|
||||||
type: 'api_request_failed',
|
}
|
||||||
severity: 'error',
|
} else if (forceAuth) {
|
||||||
summary: error instanceof Error ? error.message : String(error),
|
const authError = await requireAuth(req, url.searchParams.get('token'))
|
||||||
details: { path: url.pathname, method: req.method, error },
|
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)
|
if (upgraded) return undefined
|
||||||
return withCors(Response.json(
|
return new Response('WebSocket upgrade failed', { status: 400 })
|
||||||
{ 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) {
|
// Internal SDK WebSocket used by the spawned Claude CLI.
|
||||||
const authError = await requireH5Token(req)
|
if (url.pathname.startsWith('/sdk/')) {
|
||||||
if (authError) {
|
if (classifyH5Request(req, url, h5RequestContext) !== 'internal-sdk') {
|
||||||
return withCors(authError, cors)
|
return h5AccessControlRejectedResponse()
|
||||||
}
|
}
|
||||||
} else if (forceAuth) {
|
|
||||||
const authError = await requireAuth(req)
|
if (cors.rejected) {
|
||||||
if (authError) {
|
return corsRejectedResponse(cors)
|
||||||
return withCors(authError, cors)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
try {
|
if (forceAuth) {
|
||||||
const response = await handleProxyRequest(req, url)
|
const authError = await requireAuth(req, url.searchParams.get('token'))
|
||||||
return withCors(response, cors)
|
if (authError) {
|
||||||
} catch (error) {
|
return withCors(authError, cors)
|
||||||
void diagnosticsService.recordEvent({
|
}
|
||||||
type: 'proxy_request_failed',
|
}
|
||||||
severity: 'error',
|
|
||||||
summary: error instanceof Error ? error.message : String(error),
|
const sessionId = url.pathname.split('/').pop() || ''
|
||||||
details: { path: url.pathname, method: req.method, error },
|
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)
|
if (upgraded) return undefined
|
||||||
return withCors(Response.json(
|
return new Response('WebSocket upgrade failed', { status: 400 })
|
||||||
{ 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(
|
if (url.pathname === '/callback') {
|
||||||
{ status: 'ok', timestamp: new Date().toISOString() },
|
return handleHahaOAuthCallback(url)
|
||||||
{ headers: cors.headers },
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Static H5 shell/assets are non-secret bootstrap content and must load
|
if (url.pathname === '/callback/openai') {
|
||||||
// before the browser can read the QR token; API/proxy/ws stay protected above.
|
return handleHahaOpenAIOAuthCallback(url)
|
||||||
const staticResponse = await handleStaticH5Request(req, url)
|
}
|
||||||
if (staticResponse) {
|
|
||||||
return staticResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// Start watching ~/.claude/teams/ for real-time WebSocket push
|
||||||
teamWatcher.start()
|
teamWatcher.start()
|
||||||
@ -373,8 +382,8 @@ export function startServer(port = PORT, host = HOST) {
|
|||||||
|
|
||||||
void ensureDesktopCliLauncherInstalled().catch((error) => {
|
void ensureDesktopCliLauncherInstalled().catch((error) => {
|
||||||
console.error(
|
console.error(
|
||||||
'[desktop-cli-launcher] failed to install bundled launcher:',
|
'[desktop-cli-launcher] failed to install bundled launcher:',
|
||||||
error instanceof Error ? error.message : error,
|
error instanceof Error ? error.message : error,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -390,7 +399,7 @@ function cleanupAllSessions() {
|
|||||||
if (active.length > 0) {
|
if (active.length > 0) {
|
||||||
console.log(`[Server] Shutting down — killing ${active.length} CLI subprocess(es)`)
|
console.log(`[Server] Shutting down — killing ${active.length} CLI subprocess(es)`)
|
||||||
for (const sessionId of active) {
|
for (const sessionId of active) {
|
||||||
conversationService.stopSession(sessionId)
|
conversationService.stopSession(sessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1276,18 +1276,34 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
|
|||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
if (subtype === 'task_started') {
|
if (subtype === 'task_started') {
|
||||||
return [{
|
return [
|
||||||
type: 'status',
|
{
|
||||||
state: 'tool_executing',
|
type: 'system_notification',
|
||||||
verb: cliMsg.message || 'Task started',
|
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') {
|
if (subtype === 'task_progress') {
|
||||||
return [{
|
return [
|
||||||
type: 'status',
|
{
|
||||||
state: 'tool_executing',
|
type: 'system_notification',
|
||||||
verb: cliMsg.message || 'Task in progress',
|
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') {
|
if (subtype === 'session_state_changed') {
|
||||||
return [{
|
return [{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user