feat(trace): add session trace monitoring (#606)

Tested:
- cd desktop && bun run test -- --run src/pages/TraceList.test.tsx
- bun test src/server/__tests__/trace-capture.test.ts
- bun run check:desktop
- bun run check:server

Scope-risk: broad
This commit is contained in:
程序员阿江(Relakkes) 2026-06-10 17:06:50 +08:00
parent 56661c7967
commit 0d45439fbe
50 changed files with 6914 additions and 70 deletions

View File

@ -21,6 +21,8 @@ describe('Electron IPC capabilities', () => {
it('validates structured payloads before they reach ipcRenderer.invoke', () => {
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.shellOpen, 'https://example.com')).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.shellOpen, { url: 'https://example.com' })).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '4673a448-9e2c-475e-898d-9aa0ee2d1ab7')).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '../escape')).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, undefined)).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, {})).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowStartDragging, undefined)).toBe(true)

View File

@ -12,6 +12,12 @@ const booleanPayload: Validator = value => typeof value === 'boolean'
const hasOnlyKeys = (value: Record<string, unknown>, allowedKeys: string[]) =>
Object.keys(value).every(key => allowedKeys.includes(key))
const sessionIdPayload: Validator = value =>
typeof value === 'string'
&& value.length > 0
&& value.length <= 200
&& /^[A-Za-z0-9._:-]+$/.test(value)
const commandInvoke: Validator = value =>
isRecord(value)
&& typeof value.command === 'string'
@ -80,6 +86,7 @@ export const ELECTRON_IPC_VALIDATORS = {
[ELECTRON_IPC_CHANNELS.commandInvoke]: commandInvoke,
[ELECTRON_IPC_CHANNELS.shellOpen]: stringPayload,
[ELECTRON_IPC_CHANNELS.shellOpenPath]: stringPayload,
[ELECTRON_IPC_CHANNELS.traceOpenWindow]: sessionIdPayload,
[ELECTRON_IPC_CHANNELS.dialogOpen]: optionalRecord,
[ELECTRON_IPC_CHANNELS.dialogSave]: optionalRecord,
[ELECTRON_IPC_CHANNELS.updateCheck]: updateCheckOptions,

View File

@ -4,6 +4,7 @@ export const ELECTRON_IPC_CHANNELS = {
commandInvoke: 'desktop:command:invoke',
shellOpen: 'desktop:shell:open',
shellOpenPath: 'desktop:shell:open-path',
traceOpenWindow: 'desktop:trace:open-window',
dialogOpen: 'desktop:dialog:open',
dialogSave: 'desktop:dialog:save',
updateCheck: 'desktop:update:check',

View File

@ -50,6 +50,7 @@ let serverRuntime: ElectronServerRuntime | null = null
let updaterService: ElectronUpdaterService | null = null
let terminalService: ElectronTerminalService | null = null
let previewService: ElectronPreviewService | null = null
const traceWindows = new Map<string, BrowserWindow>()
let isQuitting = false
let trayController: TrayController | null = null
@ -84,6 +85,56 @@ function rendererEntry() {
})
}
async function loadRendererEntry(
window: BrowserWindow,
query?: Record<string, string>,
) {
const entry = rendererEntry()
if (/^https?:\/\//.test(entry)) {
const url = new URL(entry)
for (const [key, value] of Object.entries(query ?? {})) {
url.searchParams.set(key, value)
}
await window.loadURL(url.toString())
} else {
await window.loadFile(entry, query ? { query } : undefined)
}
}
async function openTraceWindow(sessionId: string) {
const existing = traceWindows.get(sessionId)
if (existing && !existing.isDestroyed()) {
showMainWindow(existing, app)
return
}
const traceWindow = new BrowserWindow({
width: 1180,
height: 780,
minWidth: 860,
minHeight: 560,
title: 'Trace',
autoHideMenuBar: true,
show: false,
webPreferences: {
preload: preloadPath(),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
traceWindows.set(sessionId, traceWindow)
traceWindow.on('closed', () => {
traceWindows.delete(sessionId)
})
installMainWindowNavigationGuards(traceWindow.webContents, { openExternal: openExternalUrl })
await loadRendererEntry(traceWindow, {
traceWindow: '1',
traceSessionId: sessionId,
})
showMainWindow(traceWindow, app)
}
function getServerRuntime() {
serverRuntime ??= new ElectronServerRuntime({
desktopRoot: unpackedRoot(),
@ -208,6 +259,7 @@ function registerIpcHandlers() {
registerHandler(ELECTRON_IPC_CHANNELS.commandInvoke, (_event, payload) => handleCommandInvoke(payload))
registerHandler(ELECTRON_IPC_CHANNELS.shellOpen, (_event, payload) => openExternalUrl(String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.shellOpenPath, (_event, payload) => openSystemPath(String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.traceOpenWindow, (_event, payload) => openTraceWindow(String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.dialogOpen, (event, payload) =>
openDialog(currentWindow(event), payload as Parameters<typeof openDialog>[1]))
registerHandler(ELECTRON_IPC_CHANNELS.dialogSave, (event, payload) =>
@ -339,12 +391,7 @@ async function createMainWindow() {
writeWindowSmokeSnapshot(mainWindow, 'after-create')
const entry = rendererEntry()
if (/^https?:\/\//.test(entry)) {
await mainWindow.loadURL(entry)
} else {
await mainWindow.loadFile(entry)
}
await loadRendererEntry(mainWindow)
restoreWindowMaximized(mainWindow, restoredState)
showMainWindow(mainWindow, app)

View File

@ -2,6 +2,7 @@ import { api } from './client'
import type { AgentTaskNotification } from '../types/chat'
import type { SessionListItem, MessageEntry } from '../types/session'
import type { PermissionMode } from '../types/settings'
import type { TraceSession } from '../types/trace'
type SessionsResponse = { sessions: SessionListItem[]; total: number }
type MessagesResponse = {
@ -320,6 +321,10 @@ export const sessionsApi = {
return api.get<MessagesResponse>(`/api/sessions/${sessionId}/messages`)
},
getTrace(sessionId: string) {
return api.get<TraceSession>(`/api/sessions/${sessionId}/trace`)
},
create(input?: string | CreateSessionRequest) {
const body = typeof input === 'string'
? (input ? { workDir: input } : {})

21
desktop/src/api/traces.ts Normal file
View File

@ -0,0 +1,21 @@
import { api } from './client'
import type { TraceCaptureSettings, TraceSessionList } from '../types/trace'
export const tracesApi = {
list(options?: { limit?: number; offset?: number; query?: string }) {
const params = new URLSearchParams()
if (options?.limit !== undefined) params.set('limit', String(options.limit))
if (options?.offset !== undefined) params.set('offset', String(options.offset))
if (options?.query) params.set('q', options.query)
const suffix = params.toString() ? `?${params}` : ''
return api.get<TraceSessionList>(`/api/traces${suffix}`)
},
getSettings() {
return api.get<TraceCaptureSettings>('/api/traces/settings')
},
updateSettings(settings: Partial<Pick<TraceCaptureSettings, 'enabled'>>) {
return api.put<TraceCaptureSettings>('/api/traces/settings', settings)
},
}

View File

@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
connectToSession: vi.fn(),
setActiveTab: vi.fn(),
openTab: vi.fn(),
openTraceTab: vi.fn(),
tabState: {
activeTabId: null as string | null,
tabs: [] as Array<{ sessionId: string; title: string; type: string; status: string }>,
@ -51,6 +52,7 @@ vi.mock('../../stores/tabStore', () => {
activeTabId: mocks.tabState.activeTabId,
tabs: mocks.tabState.tabs,
openTab: mocks.openTab,
openTraceTab: mocks.openTraceTab,
setActiveTab: mocks.setActiveTab,
})
useTabStore.setState = (next: { activeTabId?: string | null }) => {
@ -100,6 +102,14 @@ vi.mock('./H5ConnectionView', () => ({
),
}))
vi.mock('../../pages/TraceSession', () => ({
TraceSession: ({ sessionId, standalone }: { sessionId: string; standalone?: boolean }) => (
<section data-standalone={standalone ? 'true' : 'false'} data-testid="trace-session">
trace:{sessionId}
</section>
),
}))
vi.mock('../shared/Toast', () => ({
ToastContainer: () => null,
}))
@ -119,6 +129,7 @@ describe('AppShell boot flow', () => {
mocks.fetchAll.mockResolvedValue(undefined)
mocks.restoreTabs.mockResolvedValue(undefined)
mocks.openTab.mockReset()
mocks.openTraceTab.mockReset()
mocks.setActiveTab.mockImplementation((sessionId: string) => {
mocks.tabState.activeTabId = sessionId
})
@ -127,6 +138,7 @@ describe('AppShell boot flow', () => {
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useUIStore.setState({ sidebarOpen: true })
Reflect.deleteProperty(window, 'desktopHost')
window.history.pushState({}, '', '/')
})
it('renders the desktop chrome after server and settings bootstrap', async () => {
@ -181,6 +193,32 @@ describe('AppShell boot flow', () => {
})
})
it('opens a trace tab from a session-scoped trace deep link', async () => {
window.history.pushState({}, '', '/?traceSessionId=session-deep-link')
render(<AppShell />)
await screen.findByText('sidebar loaded')
await waitFor(() => {
expect(mocks.openTraceTab).toHaveBeenCalledWith(
'session-deep-link',
'Trace: session-',
)
})
expect(mocks.connectToSession).not.toHaveBeenCalled()
})
it('renders a dedicated trace window shell from traceWindow deep links', async () => {
window.history.pushState({}, '', '/?traceWindow=1&traceSessionId=session-window')
render(<AppShell />)
expect(await screen.findByTestId('trace-session')).toHaveTextContent('trace:session-window')
expect(screen.getByTestId('trace-session')).toHaveAttribute('data-standalone', 'true')
expect(screen.queryByText('sidebar loaded')).not.toBeInTheDocument()
expect(mocks.restoreTabs).not.toHaveBeenCalled()
})
it('routes native menu navigation through the desktop host', async () => {
let navigate: ((target: string) => void) | undefined
const unlisten = vi.fn()

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type HTMLAttributes } from 'react'
import { useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react'
import { Sidebar } from './Sidebar'
import { ContentRouter } from './ContentRouter'
import { ToastContainer } from '../shared/Toast'
@ -23,6 +23,9 @@ import { useTranslation } from '../../i18n'
import { H5ConnectionView } from './H5ConnectionView'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import type { Tab } from '../../stores/tabStore'
import { getTraceLaunchRequest } from '../../lib/traceLaunch'
import { TraceList } from '../../pages/TraceList'
import { TraceSession } from '../../pages/TraceSession'
function isChatTab(tab: Tab | undefined) {
return tab?.type === 'session'
@ -39,6 +42,7 @@ export function AppShell() {
const [bootstrapNonce, setBootstrapNonce] = useState(0)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
const t = useTranslation()
const traceLaunch = useMemo(() => getTraceLaunchRequest(), [])
const desktopRuntime = isDesktopRuntime()
const isMobileShell = useMobileViewport() && !desktopRuntime
const tabs = useTabStore((s) => s.tabs)
@ -84,8 +88,17 @@ export function AppShell() {
}
void (async () => {
if (traceLaunch.windowMode) return
await useTabStore.getState().restoreTabs()
if (cancelled) return
if (traceLaunch.sessionId) {
useTabStore.getState().openTraceTab(
traceLaunch.sessionId,
`Trace: ${traceLaunch.sessionId.slice(0, 8)}`,
)
return
}
const { activeTabId: activeId, tabs } = useTabStore.getState()
const activeTab = tabs.find((tab) => tab.sessionId === activeId)
if (activeId && activeTab?.type === 'session') {
@ -111,7 +124,7 @@ export function AppShell() {
return () => {
cancelled = true
}
}, [bootstrapNonce, fetchSettings, desktopRuntime])
}, [bootstrapNonce, fetchSettings, desktopRuntime, traceLaunch])
// Listen for macOS native menu navigation events (About / Settings)
useEffect(() => {
@ -194,6 +207,19 @@ export function AppShell() {
)
}
if (traceLaunch.windowMode) {
return (
<div className="app-shell-viewport flex overflow-hidden bg-[var(--color-surface)] text-[var(--color-text-primary)]">
{traceLaunch.sessionId ? (
<TraceSession sessionId={traceLaunch.sessionId} standalone />
) : (
<TraceList />
)}
<ToastContainer />
</div>
)
}
return (
<div className={`app-shell app-shell-viewport flex overflow-hidden bg-[var(--color-surface)]${isMobileShell ? ' app-shell--mobile' : ''}`}>
{isMobileShell && effectiveSidebarOpen ? (

View File

@ -26,6 +26,14 @@ vi.mock('../../pages/TerminalSettings', () => ({
),
}))
vi.mock('../../pages/TraceSession', () => ({
TraceSession: ({ sessionId }: { sessionId: string }) => <div data-testid="trace-session">trace:{sessionId}</div>,
}))
vi.mock('../../pages/TraceList', () => ({
TraceList: () => <div data-testid="trace-list" />,
}))
import { ContentRouter } from './ContentRouter'
import { useTabStore } from '../../stores/tabStore'
@ -95,4 +103,39 @@ describe('ContentRouter terminal tabs', () => {
expect(useTabStore.getState().activeTabId).not.toBe('__terminal__1')
expect(useTabStore.getState().tabs.find((tab) => tab.sessionId === useTabStore.getState().activeTabId)?.terminalCwd).toBe('/tmp/project')
})
it('renders trace tabs without mounting the chat session surface', () => {
useTabStore.setState({
tabs: [{
sessionId: '__trace__session-1',
title: 'Trace',
type: 'trace',
status: 'idle',
traceSessionId: 'session-1',
}],
activeTabId: '__trace__session-1',
})
render(<ContentRouter />)
expect(screen.getByTestId('trace-session')).toHaveTextContent('trace:session-1')
expect(screen.queryByTestId('active-session')).not.toBeInTheDocument()
})
it('renders the trace list tab without mounting the chat session surface', () => {
useTabStore.setState({
tabs: [{
sessionId: '__traces__',
title: 'Trace',
type: 'traces',
status: 'idle',
}],
activeTabId: '__traces__',
})
render(<ContentRouter />)
expect(screen.getByTestId('trace-list')).toBeInTheDocument()
expect(screen.queryByTestId('active-session')).not.toBeInTheDocument()
})
})

View File

@ -5,6 +5,8 @@ import { ActiveSession } from '../../pages/ActiveSession'
import { ScheduledTasks } from '../../pages/ScheduledTasks'
import { Settings } from '../../pages/Settings'
import { TerminalSettings } from '../../pages/TerminalSettings'
import { TraceList } from '../../pages/TraceList'
import { TraceSession } from '../../pages/TraceSession'
export function ContentRouter() {
const activeTabId = useTabStore((s) => s.activeTabId)
@ -19,6 +21,11 @@ export function ContentRouter() {
page = <Settings />
} else if (activeTabType === 'scheduled') {
page = <ScheduledTasks />
} else if (activeTabType === 'trace') {
const traceSessionId = tabs.find((t) => t.sessionId === activeTabId)?.traceSessionId
page = traceSessionId ? <TraceSession sessionId={traceSessionId} /> : <EmptySession />
} else if (activeTabType === 'traces') {
page = <TraceList />
} else if (activeTabType !== 'terminal') {
page = <ActiveSession />
}

View File

@ -5,7 +5,7 @@ import { useUIStore } from '../../stores/uiStore'
import { useTranslation, type TranslationKey } from '../../i18n'
import { ConfirmDialog } from '../shared/ConfirmDialog'
import type { SessionListItem } from '../../types/session'
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore'
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID, TRACE_LIST_TAB_ID } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { desktopUiPreferencesApi, type SidebarProjectPreferences } from '../../api/desktopUiPreferences'
@ -686,6 +686,21 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
{t('sidebar.scheduled')}
</NavItem>
)}
{!isMobile && (
<NavItem
active={activeTabId === TRACE_LIST_TAB_ID}
collapsed={!expanded}
label={t('sidebar.traces')}
touchFriendly={isMobile}
onClick={() => {
useTabStore.getState().openTracesTab(t('sidebar.traces'))
closeMobileDrawer()
}}
icon={<span className="material-symbols-outlined text-[18px]">account_tree</span>}
>
{t('sidebar.traces')}
</NavItem>
)}
</div>
{expanded ? (

View File

@ -4,6 +4,8 @@ import {
SCHEDULED_TAB_ID,
SETTINGS_TAB_ID,
TERMINAL_TAB_PREFIX,
TRACE_LIST_TAB_ID,
TRACE_TAB_PREFIX,
useTabStore,
type Tab,
} from '../../stores/tabStore'
@ -40,7 +42,9 @@ function isSessionTabId(tabId: string | null) {
if (!tabId) return false
return tabId !== SETTINGS_TAB_ID &&
tabId !== SCHEDULED_TAB_ID &&
!tabId.startsWith(TERMINAL_TAB_PREFIX)
tabId !== TRACE_LIST_TAB_ID &&
!tabId.startsWith(TERMINAL_TAB_PREFIX) &&
!tabId.startsWith(TRACE_TAB_PREFIX)
}
export function TabBar() {

View File

@ -16,10 +16,12 @@ export const en = {
'common.active': 'ACTIVE',
'common.error': 'Error',
'common.copyFailed': 'Copy failed.',
'common.copied': 'Copied',
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': 'New session',
'sidebar.scheduled': 'Scheduled',
'sidebar.traces': 'Trace',
'sidebar.terminal': 'Terminal',
'sidebar.settings': 'Settings',
'sidebar.searchPlaceholder': 'Search sessions...',
@ -969,6 +971,11 @@ export const en = {
'settings.general.notificationsOpenSettings': 'Open Settings',
'settings.general.notificationsTestTitle': 'Claude Code Haha notifications are enabled',
'settings.general.notificationsTestBody': 'Permission prompts and completed agent replies will now use system notifications.',
'settings.general.traceTitle': 'Agent trace',
'settings.general.traceDescription': 'Collect local model request traces for stuck sessions, failed calls, and unexpected waits.',
'settings.general.traceEnabled': 'Collect agent traces',
'settings.general.traceHintOn': 'New sessions write compact requests, responses, and status events to the local cc-haha traces directory.',
'settings.general.traceHintOff': 'No new traces will be written. Existing records remain available in the trace list.',
'settings.general.chatSendBehaviorTitle': 'Message Sending',
'settings.general.chatSendBehaviorDescription': 'Choose how the desktop chat composer submits messages.',
'settings.general.chatSendBehaviorEnter': 'Enter sends',
@ -1649,6 +1656,143 @@ export const en = {
'session.timeMinutes': '{n}m ago',
'session.timeHours': '{n}h ago',
'session.timeDays': '{n}d ago',
'trace.open': 'Trace',
'trace.title': 'Model trace',
'trace.loadFailed': 'Failed to load trace',
'trace.noModel': 'No model calls',
'trace.apiCalls': 'API calls',
'trace.failedCalls': 'Failed',
'trace.duration': 'Duration',
'trace.models': 'Models',
'trace.request': 'Request',
'trace.response': 'Response',
'trace.status': 'Status',
'trace.querySource': 'Source',
'trace.size': 'Size',
'trace.live': 'Live',
'trace.updatedAt': 'Updated',
'trace.copySessionId': 'Copy session ID',
'trace.refresh': 'Refresh trace',
'trace.openWindow': 'Open in separate window',
'trace.missingSession': 'Missing trace session id',
'trace.noResponse': 'No response body captured',
'trace.truncated': 'Preview truncated to protect desktop performance.',
'trace.emptyTitle': 'No trace calls yet',
'trace.emptyBody': 'This session has no captured model calls. New desktop sessions record compact request and response previews automatically.',
'trace.sessionTrace': 'Session trace',
'trace.tokens': 'Tokens',
'trace.snapshotEmpty': 'Trace snapshot is empty.',
'trace.diagnosis': 'Diagnosis',
'trace.lastActivity': 'Last activity',
'trace.errors': 'Errors',
'trace.pendingModels': 'Pending models',
'trace.pendingTools': 'Pending tools',
'trace.focus': 'Focus',
'trace.runTree': 'Run tree',
'trace.searchSpans': 'Search spans',
'trace.noMatchingSpans': 'No matching spans',
'trace.filter.all': 'All',
'trace.filter.llm': 'LLM',
'trace.filter.tools': 'Tools',
'trace.filter.errors': 'Errors',
'trace.sidechain': 'sub',
'trace.thread': 'Thread',
'trace.threadStats': '{turns} turns · {spans} spans · live polling',
'trace.turnCount': '{count} turns',
'trace.spanCount': '{count} spans',
'trace.turnLabel': 'Turn {index}',
'trace.stat.llm': 'LLM',
'trace.stat.tools': 'tools',
'trace.stat.errors': 'errors',
'trace.waitingForResult': 'Waiting for result',
'trace.emptyMessage': 'Empty message',
'trace.inspectorAria': 'Trace span details',
'trace.tab.overview': 'Overview',
'trace.tab.input': 'Input',
'trace.tab.output': 'Output',
'trace.tab.metadata': 'Metadata',
'trace.tab.raw': 'Raw',
'trace.kind': 'Kind',
'trace.started': 'Started',
'trace.provider': 'Provider',
'trace.model': 'Model',
'trace.notAvailable': 'n/a',
'trace.sessionSummary': 'Session summary',
'trace.childSpans': 'Child spans',
'trace.requestBody': 'Request body',
'trace.requestHeaders': 'Request headers',
'trace.toolArguments': 'Tool arguments',
'trace.messageContent': 'Message content',
'trace.eventPayload': 'Event payload',
'trace.noInput': 'No input captured for this span.',
'trace.responseBody': 'Response body',
'trace.responseHeaders': 'Response headers',
'trace.toolResult': 'Tool result',
'trace.toolResultPending': 'Tool result has not arrived yet.',
'trace.noSeparateOutput': 'This span does not have a separate output.',
'trace.spanMetadata': 'Span metadata',
'trace.rawScope.span': 'This span',
'trace.rawScope.full': 'Full trace',
'trace.rawSpanJson': 'Raw span JSON',
'trace.rawTraceJson': 'Raw trace JSON',
'trace.truncatedShort': 'truncated',
'trace.emptyBodyShort': 'Empty body',
'trace.noData': 'No data',
'trace.spanFailed': 'This span failed.',
'trace.trace': 'Trace',
'trace.turns': 'Turns',
'trace.llmCalls': 'LLM calls',
'trace.toolCalls': 'Tool calls',
'trace.kind.session': 'Session',
'trace.kind.turn': 'Turn',
'trace.kind.llm': 'LLM call',
'trace.kind.tool': 'Tool call',
'trace.kind.toolResult': 'Tool result',
'trace.kind.message': 'Message',
'trace.kind.event': 'Event',
'trace.status.ok': 'ok',
'trace.status.error': 'error',
'trace.status.pending': 'pending',
'trace.message.user': 'User message',
'trace.message.assistant': 'Assistant message',
'trace.message.system': 'System message',
'trace.message.toolRequest': 'Assistant tool request',
'trace.message.toolResult': 'Tool result',
'trace.toolError': 'Tool error',
'trace.modelCall': 'Model call',
'trace.modelCalls': '{count} model calls',
'trace.sessionActivity': 'Session activity',
'trace.emptyValue': 'empty',
'trace.diagnosis.modelError': 'Model call failed',
'trace.diagnosis.toolError': 'Tool execution failed',
'trace.diagnosis.eventError': 'Trace event failed',
'trace.diagnosis.pendingModel': 'Waiting for model response',
'trace.diagnosis.pendingTool': 'Waiting for tool result',
'trace.diagnosis.waitingForAgent': 'User message has not entered an agent step',
'trace.diagnosis.empty': 'No diagnostic events',
'trace.diagnosis.healthy': 'No blocking point detected',
'trace.event.apiCallStarted': 'API call started',
'trace.event.apiCallCompleted': 'API call completed',
'trace.event.apiCallFailed': 'API call failed',
'trace.event.responseCaptureFailed': 'Response capture failed',
'trace.event.upstreamFetchStarted': 'Upstream fetch started',
'trace.event.upstreamFetchCompleted': 'Upstream fetch completed',
'trace.event.upstreamFetchFailed': 'Upstream fetch failed',
'trace.list.eyebrow': 'Agent trace',
'trace.list.title': 'Trace list',
'trace.list.collecting': 'Collecting',
'trace.list.paused': 'Paused',
'trace.list.settings': 'Trace settings',
'trace.list.sessions': 'Sessions',
'trace.list.session': 'Session',
'trace.list.actions': 'Actions',
'trace.list.searchPlaceholder': 'Search title, session ID, or project path',
'trace.list.emptyTitle': 'No trace records yet',
'trace.list.emptyBody': 'When collection is enabled, new desktop sessions write model-call traces to the local traces directory.',
'trace.list.loadFailed': 'Failed to load trace list',
'trace.list.fileSize': 'File size',
'trace.list.loadedCount': 'Showing {shown} of {total}',
'trace.list.loadMore': 'Load more',
// ─── App Shell ──────────────────────────────────────
'app.serverFailed': 'Local server failed to start',

View File

@ -18,10 +18,12 @@ export const jp: Record<TranslationKey, string> = {
'common.active': 'アクティブ',
'common.error': 'エラー',
'common.copyFailed': 'コピーに失敗しました。',
'common.copied': 'コピーしました',
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '新しいセッション',
'sidebar.scheduled': 'スケジュール',
'sidebar.traces': 'Trace',
'sidebar.terminal': 'ターミナル',
'sidebar.settings': '設定',
'sidebar.searchPlaceholder': 'セッションを検索...',
@ -971,6 +973,11 @@ export const jp: Record<TranslationKey, string> = {
'settings.general.notificationsOpenSettings': '設定を開く',
'settings.general.notificationsTestTitle': 'Claude Code Haha の通知が有効になりました',
'settings.general.notificationsTestBody': '権限の確認と完了したエージェントの応答に、これからシステム通知が使用されます。',
'settings.general.traceTitle': 'Agent Trace',
'settings.general.traceDescription': '停止、失敗、予期しない待機を調査するために、ローカルセッションのモデル要求トレースを収集します。',
'settings.general.traceEnabled': 'Agent Trace を収集',
'settings.general.traceHintOn': '新しいセッションは、簡潔な要求、応答、状態イベントをローカルの cc-haha traces ディレクトリに書き込みます。',
'settings.general.traceHintOff': '新しい Trace は書き込まれません。既存の記録は Trace 一覧で引き続き確認できます。',
'settings.general.chatSendBehaviorTitle': 'メッセージの送信',
'settings.general.chatSendBehaviorDescription': 'デスクトップのチャット入力欄でメッセージを送信する方法を選択します。',
'settings.general.chatSendBehaviorEnter': 'Enter で送信',
@ -1651,6 +1658,143 @@ export const jp: Record<TranslationKey, string> = {
'session.timeMinutes': '{n} 分前',
'session.timeHours': '{n} 時間前',
'session.timeDays': '{n} 日前',
'trace.open': 'Trace',
'trace.title': 'モデル Trace',
'trace.loadFailed': 'Trace の読み込みに失敗しました',
'trace.noModel': 'モデル呼び出しなし',
'trace.apiCalls': 'API 呼び出し',
'trace.failedCalls': '失敗',
'trace.duration': '所要時間',
'trace.models': 'モデル',
'trace.request': 'リクエスト',
'trace.response': 'レスポンス',
'trace.status': 'ステータス',
'trace.querySource': 'ソース',
'trace.size': 'サイズ',
'trace.live': 'ライブ',
'trace.updatedAt': '更新',
'trace.copySessionId': 'Session ID をコピー',
'trace.refresh': 'Trace を更新',
'trace.openWindow': '別ウィンドウで開く',
'trace.missingSession': 'Trace session id がありません',
'trace.noResponse': 'レスポンス本文はキャプチャされていません',
'trace.truncated': 'デスクトップ性能を保護するため、プレビューは切り詰められています。',
'trace.emptyTitle': 'Trace 呼び出しはまだありません',
'trace.emptyBody': 'このセッションにはキャプチャ済みのモデル呼び出しがありません。新しいデスクトップセッションでは、軽量なリクエストとレスポンスのプレビューを自動記録します。',
'trace.sessionTrace': 'セッショントレース',
'trace.tokens': 'Tokens',
'trace.snapshotEmpty': 'Trace スナップショットは空です。',
'trace.diagnosis': '診断',
'trace.lastActivity': '最終アクティビティ',
'trace.errors': 'エラー',
'trace.pendingModels': '待機中のモデル',
'trace.pendingTools': '待機中のツール',
'trace.focus': '注目箇所',
'trace.runTree': '実行ツリー',
'trace.searchSpans': 'Span を検索',
'trace.noMatchingSpans': '一致する Span はありません',
'trace.filter.all': 'すべて',
'trace.filter.llm': 'LLM',
'trace.filter.tools': 'ツール',
'trace.filter.errors': 'エラー',
'trace.sidechain': '子',
'trace.thread': 'スレッド',
'trace.threadStats': '{turns} ターン · {spans} Span · ライブポーリング',
'trace.turnCount': '{count} ターン',
'trace.spanCount': '{count} Span',
'trace.turnLabel': 'ターン {index}',
'trace.stat.llm': 'LLM',
'trace.stat.tools': 'ツール',
'trace.stat.errors': 'エラー',
'trace.waitingForResult': '結果待ち',
'trace.emptyMessage': '空のメッセージ',
'trace.inspectorAria': 'Trace Span 詳細',
'trace.tab.overview': '概要',
'trace.tab.input': '入力',
'trace.tab.output': '出力',
'trace.tab.metadata': 'メタデータ',
'trace.tab.raw': 'Raw',
'trace.kind': '種類',
'trace.started': '開始',
'trace.provider': 'Provider',
'trace.model': 'モデル',
'trace.notAvailable': 'なし',
'trace.sessionSummary': 'セッション概要',
'trace.childSpans': '子 Span',
'trace.requestBody': 'リクエスト本文',
'trace.requestHeaders': 'リクエストヘッダー',
'trace.toolArguments': 'ツール引数',
'trace.messageContent': 'メッセージ内容',
'trace.eventPayload': 'イベントペイロード',
'trace.noInput': 'この Span の入力はキャプチャされていません。',
'trace.responseBody': 'レスポンス本文',
'trace.responseHeaders': 'レスポンスヘッダー',
'trace.toolResult': 'ツール結果',
'trace.toolResultPending': 'ツール結果はまだ届いていません。',
'trace.noSeparateOutput': 'この Span には個別の出力がありません。',
'trace.spanMetadata': 'Span メタデータ',
'trace.rawScope.span': 'この Span',
'trace.rawScope.full': 'Trace 全体',
'trace.rawSpanJson': 'Raw Span JSON',
'trace.rawTraceJson': 'Raw Trace JSON',
'trace.truncatedShort': '切り詰め済み',
'trace.emptyBodyShort': '空の本文',
'trace.noData': 'データなし',
'trace.spanFailed': 'この Span は失敗しました。',
'trace.trace': 'Trace',
'trace.turns': 'ターン',
'trace.llmCalls': 'LLM 呼び出し',
'trace.toolCalls': 'ツール呼び出し',
'trace.kind.session': 'セッション',
'trace.kind.turn': 'ターン',
'trace.kind.llm': 'LLM 呼び出し',
'trace.kind.tool': 'ツール呼び出し',
'trace.kind.toolResult': 'ツール結果',
'trace.kind.message': 'メッセージ',
'trace.kind.event': 'イベント',
'trace.status.ok': '正常',
'trace.status.error': 'エラー',
'trace.status.pending': '待機中',
'trace.message.user': 'ユーザーメッセージ',
'trace.message.assistant': 'アシスタントメッセージ',
'trace.message.system': 'システムメッセージ',
'trace.message.toolRequest': 'アシスタントのツール要求',
'trace.message.toolResult': 'ツール結果',
'trace.toolError': 'ツールエラー',
'trace.modelCall': 'モデル呼び出し',
'trace.modelCalls': '{count} 件のモデル呼び出し',
'trace.sessionActivity': 'セッションアクティビティ',
'trace.emptyValue': '空',
'trace.diagnosis.modelError': 'モデル呼び出しに失敗しました',
'trace.diagnosis.toolError': 'ツール実行に失敗しました',
'trace.diagnosis.eventError': 'トレースイベントに失敗しました',
'trace.diagnosis.pendingModel': 'モデル応答を待機中',
'trace.diagnosis.pendingTool': 'ツール結果を待機中',
'trace.diagnosis.waitingForAgent': 'ユーザーメッセージはまだ agent ステップに入っていません',
'trace.diagnosis.empty': '診断イベントがありません',
'trace.diagnosis.healthy': 'ブロック箇所は検出されていません',
'trace.event.apiCallStarted': 'API 呼び出し開始',
'trace.event.apiCallCompleted': 'API 呼び出し完了',
'trace.event.apiCallFailed': 'API 呼び出し失敗',
'trace.event.responseCaptureFailed': 'レスポンスキャプチャ失敗',
'trace.event.upstreamFetchStarted': 'アップストリーム要求開始',
'trace.event.upstreamFetchCompleted': 'アップストリーム要求完了',
'trace.event.upstreamFetchFailed': 'アップストリーム要求失敗',
'trace.list.eyebrow': 'Agent Trace',
'trace.list.title': 'Trace 一覧',
'trace.list.collecting': '収集中',
'trace.list.paused': '一時停止',
'trace.list.settings': 'Trace 設定',
'trace.list.sessions': 'セッション',
'trace.list.session': 'Session',
'trace.list.actions': '操作',
'trace.list.searchPlaceholder': 'タイトル、Session ID、プロジェクトパスを検索',
'trace.list.emptyTitle': 'Trace 記録はまだありません',
'trace.list.emptyBody': '収集を有効にすると、新しいデスクトップセッションはモデル呼び出しトレースをローカルの traces ディレクトリに書き込みます。',
'trace.list.loadFailed': 'Trace 一覧の読み込みに失敗しました',
'trace.list.fileSize': 'ファイルサイズ',
'trace.list.loadedCount': '{shown} / {total} を表示中',
'trace.list.loadMore': 'さらに読み込む',
// ─── App Shell ──────────────────────────────────────
'app.serverFailed': 'ローカルサーバーの起動に失敗しました',

View File

@ -18,10 +18,12 @@ export const kr: Record<TranslationKey, string> = {
'common.active': '활성',
'common.error': '오류',
'common.copyFailed': '복사하지 못했습니다.',
'common.copied': '복사됨',
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '새 세션',
'sidebar.scheduled': '예약 작업',
'sidebar.traces': 'Trace',
'sidebar.terminal': '터미널',
'sidebar.settings': '설정',
'sidebar.searchPlaceholder': '세션 검색...',
@ -971,6 +973,11 @@ export const kr: Record<TranslationKey, string> = {
'settings.general.notificationsOpenSettings': '설정 열기',
'settings.general.notificationsTestTitle': 'Claude Code Haha 알림이 사용 설정되었습니다',
'settings.general.notificationsTestBody': '이제 권한 확인과 완료된 에이전트 응답에 시스템 알림이 사용됩니다.',
'settings.general.traceTitle': 'Agent Trace',
'settings.general.traceDescription': '멈춤, 실패, 예상치 못한 대기를 조사하기 위해 로컬 세션의 모델 요청 트레이스를 수집합니다.',
'settings.general.traceEnabled': 'Agent Trace 수집',
'settings.general.traceHintOn': '새 세션은 간결한 요청, 응답, 상태 이벤트를 로컬 cc-haha traces 디렉터리에 기록합니다.',
'settings.general.traceHintOff': '새 Trace는 기록되지 않습니다. 기존 기록은 Trace 목록에서 계속 볼 수 있습니다.',
'settings.general.chatSendBehaviorTitle': '메시지 전송',
'settings.general.chatSendBehaviorDescription': '데스크톱 채팅 입력창에서 메시지를 전송하는 방식을 선택합니다.',
'settings.general.chatSendBehaviorEnter': 'Enter로 전송',
@ -1651,6 +1658,143 @@ export const kr: Record<TranslationKey, string> = {
'session.timeMinutes': '{n}분 전',
'session.timeHours': '{n}시간 전',
'session.timeDays': '{n}일 전',
'trace.open': 'Trace',
'trace.title': '모델 Trace',
'trace.loadFailed': 'Trace를 불러오지 못했습니다',
'trace.noModel': '모델 호출 없음',
'trace.apiCalls': 'API 호출',
'trace.failedCalls': '실패',
'trace.duration': '소요 시간',
'trace.models': '모델',
'trace.request': '요청',
'trace.response': '응답',
'trace.status': '상태',
'trace.querySource': '소스',
'trace.size': '크기',
'trace.live': '실시간',
'trace.updatedAt': '업데이트',
'trace.copySessionId': 'Session ID 복사',
'trace.refresh': 'Trace 새로고침',
'trace.openWindow': '별도 창에서 열기',
'trace.missingSession': 'Trace session id가 없습니다',
'trace.noResponse': '응답 본문이 캡처되지 않았습니다',
'trace.truncated': '데스크톱 성능을 보호하기 위해 미리보기가 잘렸습니다.',
'trace.emptyTitle': '아직 Trace 호출이 없습니다',
'trace.emptyBody': '이 세션에는 캡처된 모델 호출이 없습니다. 새 데스크톱 세션은 간단한 요청 및 응답 미리보기를 자동으로 기록합니다.',
'trace.sessionTrace': '세션 Trace',
'trace.tokens': 'Tokens',
'trace.snapshotEmpty': 'Trace 스냅샷이 비어 있습니다.',
'trace.diagnosis': '진단',
'trace.lastActivity': '마지막 활동',
'trace.errors': '오류',
'trace.pendingModels': '대기 중인 모델',
'trace.pendingTools': '대기 중인 도구',
'trace.focus': '집중 지점',
'trace.runTree': '실행 트리',
'trace.searchSpans': 'Span 검색',
'trace.noMatchingSpans': '일치하는 Span이 없습니다',
'trace.filter.all': '전체',
'trace.filter.llm': 'LLM',
'trace.filter.tools': '도구',
'trace.filter.errors': '오류',
'trace.sidechain': '하위',
'trace.thread': '스레드',
'trace.threadStats': '{turns} 턴 · {spans} Span · 실시간 폴링',
'trace.turnCount': '{count} 턴',
'trace.spanCount': '{count} Span',
'trace.turnLabel': '{index}번째 턴',
'trace.stat.llm': 'LLM',
'trace.stat.tools': '도구',
'trace.stat.errors': '오류',
'trace.waitingForResult': '결과 대기 중',
'trace.emptyMessage': '빈 메시지',
'trace.inspectorAria': 'Trace Span 상세',
'trace.tab.overview': '개요',
'trace.tab.input': '입력',
'trace.tab.output': '출력',
'trace.tab.metadata': '메타데이터',
'trace.tab.raw': 'Raw',
'trace.kind': '종류',
'trace.started': '시작',
'trace.provider': 'Provider',
'trace.model': '모델',
'trace.notAvailable': '없음',
'trace.sessionSummary': '세션 요약',
'trace.childSpans': '하위 Span',
'trace.requestBody': '요청 본문',
'trace.requestHeaders': '요청 헤더',
'trace.toolArguments': '도구 인수',
'trace.messageContent': '메시지 내용',
'trace.eventPayload': '이벤트 페이로드',
'trace.noInput': '이 Span에는 캡처된 입력이 없습니다.',
'trace.responseBody': '응답 본문',
'trace.responseHeaders': '응답 헤더',
'trace.toolResult': '도구 결과',
'trace.toolResultPending': '도구 결과가 아직 도착하지 않았습니다.',
'trace.noSeparateOutput': '이 Span에는 별도 출력이 없습니다.',
'trace.spanMetadata': 'Span 메타데이터',
'trace.rawScope.span': '현재 Span',
'trace.rawScope.full': '전체 Trace',
'trace.rawSpanJson': '원본 Span JSON',
'trace.rawTraceJson': '원본 Trace JSON',
'trace.truncatedShort': '잘림',
'trace.emptyBodyShort': '빈 본문',
'trace.noData': '데이터 없음',
'trace.spanFailed': '이 Span은 실패했습니다.',
'trace.trace': 'Trace',
'trace.turns': '턴',
'trace.llmCalls': 'LLM 호출',
'trace.toolCalls': '도구 호출',
'trace.kind.session': '세션',
'trace.kind.turn': '턴',
'trace.kind.llm': 'LLM 호출',
'trace.kind.tool': '도구 호출',
'trace.kind.toolResult': '도구 결과',
'trace.kind.message': '메시지',
'trace.kind.event': '이벤트',
'trace.status.ok': '정상',
'trace.status.error': '오류',
'trace.status.pending': '대기 중',
'trace.message.user': '사용자 메시지',
'trace.message.assistant': '어시스턴트 메시지',
'trace.message.system': '시스템 메시지',
'trace.message.toolRequest': '어시스턴트 도구 요청',
'trace.message.toolResult': '도구 결과',
'trace.toolError': '도구 오류',
'trace.modelCall': '모델 호출',
'trace.modelCalls': '모델 호출 {count}회',
'trace.sessionActivity': '세션 활동',
'trace.emptyValue': '비어 있음',
'trace.diagnosis.modelError': '모델 호출 실패',
'trace.diagnosis.toolError': '도구 실행 실패',
'trace.diagnosis.eventError': '트레이스 이벤트 실패',
'trace.diagnosis.pendingModel': '모델 응답 대기 중',
'trace.diagnosis.pendingTool': '도구 결과 대기 중',
'trace.diagnosis.waitingForAgent': '사용자 메시지가 아직 agent 단계에 들어가지 않았습니다',
'trace.diagnosis.empty': '진단 이벤트가 없습니다',
'trace.diagnosis.healthy': '차단 지점이 감지되지 않았습니다',
'trace.event.apiCallStarted': 'API 호출 시작',
'trace.event.apiCallCompleted': 'API 호출 완료',
'trace.event.apiCallFailed': 'API 호출 실패',
'trace.event.responseCaptureFailed': '응답 캡처 실패',
'trace.event.upstreamFetchStarted': '업스트림 요청 시작',
'trace.event.upstreamFetchCompleted': '업스트림 요청 완료',
'trace.event.upstreamFetchFailed': '업스트림 요청 실패',
'trace.list.eyebrow': 'Agent Trace',
'trace.list.title': 'Trace 목록',
'trace.list.collecting': '수집 중',
'trace.list.paused': '일시 중지됨',
'trace.list.settings': 'Trace 설정',
'trace.list.sessions': '세션',
'trace.list.session': 'Session',
'trace.list.actions': '작업',
'trace.list.searchPlaceholder': '제목, Session ID 또는 프로젝트 경로 검색',
'trace.list.emptyTitle': '아직 Trace 기록이 없습니다',
'trace.list.emptyBody': '수집을 켜면 새 데스크톱 세션이 모델 호출 트레이스를 로컬 traces 디렉터리에 기록합니다.',
'trace.list.loadFailed': 'Trace 목록을 불러오지 못했습니다',
'trace.list.fileSize': '파일 크기',
'trace.list.loadedCount': '{shown} / {total} 표시 중',
'trace.list.loadMore': '더 불러오기',
// ─── App Shell ──────────────────────────────────────
'app.serverFailed': '로컬 서버를 시작하지 못했습니다',

View File

@ -18,10 +18,12 @@ export const zh: Record<TranslationKey, string> = {
'common.active': '已啟用',
'common.error': '錯誤',
'common.copyFailed': '複製失敗。',
'common.copied': '已複製',
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '新建會話',
'sidebar.scheduled': '定時任務',
'sidebar.traces': 'Trace',
'sidebar.terminal': '終端',
'sidebar.settings': '設定',
'sidebar.searchPlaceholder': '搜尋會話...',
@ -971,6 +973,11 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.notificationsOpenSettings': '開啟系統設定',
'settings.general.notificationsTestTitle': 'Claude Code Haha 通知已啟用',
'settings.general.notificationsTestBody': '後續授權確認和 Agent 回覆完成都會透過系統通知提醒。',
'settings.general.traceTitle': 'Agent Trace',
'settings.general.traceDescription': '收集本地會話的模型請求鏈路,用於排查卡住、失敗和異常等待。',
'settings.general.traceEnabled': '收集 Agent Trace',
'settings.general.traceHintOn': '新會話會把精簡請求、回應、狀態事件寫入本機 cc-haha traces 目錄。',
'settings.general.traceHintOff': '關閉後不會寫入新的 Trace已有記錄仍可在 Trace 列表裡查看。',
'settings.general.chatSendBehaviorTitle': '訊息傳送方式',
'settings.general.chatSendBehaviorDescription': '選擇桌面端對話輸入框如何傳送訊息。',
'settings.general.chatSendBehaviorEnter': 'Enter 傳送',
@ -1651,6 +1658,143 @@ export const zh: Record<TranslationKey, string> = {
'session.timeMinutes': '{n}分鐘前',
'session.timeHours': '{n}小時前',
'session.timeDays': '{n}天前',
'trace.open': 'Trace',
'trace.title': '模型 Trace',
'trace.loadFailed': 'Trace 載入失敗',
'trace.noModel': '暫無模型呼叫',
'trace.apiCalls': 'API 呼叫',
'trace.failedCalls': '失敗',
'trace.duration': '耗時',
'trace.models': '模型',
'trace.request': '請求',
'trace.response': '回應',
'trace.status': '狀態',
'trace.querySource': '來源',
'trace.size': '大小',
'trace.live': '即時',
'trace.updatedAt': '更新',
'trace.copySessionId': '複製 Session ID',
'trace.refresh': '重新整理 Trace',
'trace.openWindow': '在獨立視窗開啟',
'trace.missingSession': '缺少 Trace session id',
'trace.noResponse': '未捕獲回應正文',
'trace.truncated': '預覽已截斷,以避免影響桌面端效能。',
'trace.emptyTitle': '暫無 Trace 呼叫',
'trace.emptyBody': '這個會話還沒有捕獲到模型呼叫。新的桌面會話會自動記錄精簡的請求和回應預覽。',
'trace.sessionTrace': '會話鏈路',
'trace.tokens': 'Tokens',
'trace.snapshotEmpty': 'Trace 快照為空。',
'trace.diagnosis': '診斷',
'trace.lastActivity': '最後活動',
'trace.errors': '錯誤',
'trace.pendingModels': '等待模型',
'trace.pendingTools': '等待工具',
'trace.focus': '關注點',
'trace.runTree': '執行樹',
'trace.searchSpans': '搜尋 Span',
'trace.noMatchingSpans': '沒有符合的 Span',
'trace.filter.all': '全部',
'trace.filter.llm': 'LLM',
'trace.filter.tools': '工具',
'trace.filter.errors': '錯誤',
'trace.sidechain': '子鏈',
'trace.thread': '對話執行緒',
'trace.threadStats': '{turns} 輪 · {spans} 個 Span · 即時輪詢',
'trace.turnCount': '{count} 輪',
'trace.spanCount': '{count} 個 Span',
'trace.turnLabel': '第 {index} 輪',
'trace.stat.llm': 'LLM',
'trace.stat.tools': '工具',
'trace.stat.errors': '錯誤',
'trace.waitingForResult': '等待結果',
'trace.emptyMessage': '空訊息',
'trace.inspectorAria': 'Trace Span 詳情',
'trace.tab.overview': '概覽',
'trace.tab.input': '輸入',
'trace.tab.output': '輸出',
'trace.tab.metadata': '元資料',
'trace.tab.raw': '原始',
'trace.kind': '類型',
'trace.started': '開始',
'trace.provider': 'Provider',
'trace.model': '模型',
'trace.notAvailable': '無',
'trace.sessionSummary': '會話摘要',
'trace.childSpans': '子 Span',
'trace.requestBody': '請求正文',
'trace.requestHeaders': '請求頭',
'trace.toolArguments': '工具參數',
'trace.messageContent': '訊息內容',
'trace.eventPayload': '事件負載',
'trace.noInput': '這個 Span 沒有捕獲輸入。',
'trace.responseBody': '回應正文',
'trace.responseHeaders': '回應頭',
'trace.toolResult': '工具結果',
'trace.toolResultPending': '工具結果還沒有返回。',
'trace.noSeparateOutput': '這個 Span 沒有單獨的輸出。',
'trace.spanMetadata': 'Span 元資料',
'trace.rawScope.span': '目前 Span',
'trace.rawScope.full': '完整 Trace',
'trace.rawSpanJson': '原始 Span JSON',
'trace.rawTraceJson': '原始 Trace JSON',
'trace.truncatedShort': '已截斷',
'trace.emptyBodyShort': '空正文',
'trace.noData': '無資料',
'trace.spanFailed': '這個 Span 失敗了。',
'trace.trace': 'Trace',
'trace.turns': '輪次',
'trace.llmCalls': 'LLM 呼叫',
'trace.toolCalls': '工具呼叫',
'trace.kind.session': '會話',
'trace.kind.turn': '輪次',
'trace.kind.llm': 'LLM 呼叫',
'trace.kind.tool': '工具呼叫',
'trace.kind.toolResult': '工具結果',
'trace.kind.message': '訊息',
'trace.kind.event': '事件',
'trace.status.ok': '正常',
'trace.status.error': '錯誤',
'trace.status.pending': '等待中',
'trace.message.user': '使用者訊息',
'trace.message.assistant': '助手訊息',
'trace.message.system': '系統訊息',
'trace.message.toolRequest': '助手請求工具',
'trace.message.toolResult': '工具結果',
'trace.toolError': '工具錯誤',
'trace.modelCall': '模型呼叫',
'trace.modelCalls': '{count} 次模型呼叫',
'trace.sessionActivity': '會話活動',
'trace.emptyValue': '空',
'trace.diagnosis.modelError': '模型呼叫失敗',
'trace.diagnosis.toolError': '工具執行失敗',
'trace.diagnosis.eventError': '鏈路事件失敗',
'trace.diagnosis.pendingModel': '正在等待模型回應',
'trace.diagnosis.pendingTool': '正在等待工具結果',
'trace.diagnosis.waitingForAgent': '使用者訊息尚未進入 agent 步驟',
'trace.diagnosis.empty': '沒有可診斷事件',
'trace.diagnosis.healthy': '未發現阻塞點',
'trace.event.apiCallStarted': 'API 呼叫開始',
'trace.event.apiCallCompleted': 'API 呼叫完成',
'trace.event.apiCallFailed': 'API 呼叫失敗',
'trace.event.responseCaptureFailed': '回應捕獲失敗',
'trace.event.upstreamFetchStarted': '上游請求開始',
'trace.event.upstreamFetchCompleted': '上游請求完成',
'trace.event.upstreamFetchFailed': '上游請求失敗',
'trace.list.eyebrow': 'Agent Trace',
'trace.list.title': 'Trace 列表',
'trace.list.collecting': '正在收集',
'trace.list.paused': '已暫停',
'trace.list.settings': 'Trace 設定',
'trace.list.sessions': '會話',
'trace.list.session': 'Session',
'trace.list.actions': '操作',
'trace.list.searchPlaceholder': '搜尋標題、Session ID 或專案路徑',
'trace.list.emptyTitle': '還沒有 Trace 記錄',
'trace.list.emptyBody': '開啟收集後,新的桌面會話會把模型呼叫鏈路寫入本地 traces 目錄。',
'trace.list.loadFailed': 'Trace 列表載入失敗',
'trace.list.fileSize': '檔案大小',
'trace.list.loadedCount': '已顯示 {shown} / {total}',
'trace.list.loadMore': '載入更多',
// ─── 應用外殼 ──────────────────────────────────────
'app.serverFailed': '本地服務啟動失敗',

View File

@ -18,10 +18,12 @@ export const zh: Record<TranslationKey, string> = {
'common.active': '已激活',
'common.error': '错误',
'common.copyFailed': '复制失败。',
'common.copied': '已复制',
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '新建会话',
'sidebar.scheduled': '定时任务',
'sidebar.traces': 'Trace',
'sidebar.terminal': '终端',
'sidebar.settings': '设置',
'sidebar.searchPlaceholder': '搜索会话...',
@ -971,6 +973,11 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.notificationsOpenSettings': '打开系统设置',
'settings.general.notificationsTestTitle': 'Claude Code Haha 通知已启用',
'settings.general.notificationsTestBody': '后续授权确认和 Agent 回复完成都会通过系统通知提醒。',
'settings.general.traceTitle': 'Agent Trace',
'settings.general.traceDescription': '收集本地会话的模型请求链路,用于排查卡住、失败和异常等待。',
'settings.general.traceEnabled': '收集 Agent Trace',
'settings.general.traceHintOn': '新会话会把精简请求、响应、状态事件写入本机 cc-haha traces 目录。',
'settings.general.traceHintOff': '关闭后不会写入新的 Trace已有记录仍可在 Trace 列表里查看。',
'settings.general.chatSendBehaviorTitle': '消息发送方式',
'settings.general.chatSendBehaviorDescription': '选择桌面端对话输入框如何发送消息。',
'settings.general.chatSendBehaviorEnter': 'Enter 发送',
@ -1651,6 +1658,143 @@ export const zh: Record<TranslationKey, string> = {
'session.timeMinutes': '{n}分钟前',
'session.timeHours': '{n}小时前',
'session.timeDays': '{n}天前',
'trace.open': 'Trace',
'trace.title': '模型 Trace',
'trace.loadFailed': 'Trace 加载失败',
'trace.noModel': '暂无模型调用',
'trace.apiCalls': 'API 调用',
'trace.failedCalls': '失败',
'trace.duration': '耗时',
'trace.models': '模型',
'trace.request': '请求',
'trace.response': '响应',
'trace.status': '状态',
'trace.querySource': '来源',
'trace.size': '大小',
'trace.live': '实时',
'trace.updatedAt': '更新',
'trace.copySessionId': '复制 Session ID',
'trace.refresh': '刷新 Trace',
'trace.openWindow': '在独立窗口打开',
'trace.missingSession': '缺少 Trace session id',
'trace.noResponse': '未捕获响应正文',
'trace.truncated': '预览已截断,以避免影响桌面端性能。',
'trace.emptyTitle': '暂无 Trace 调用',
'trace.emptyBody': '这个会话还没有捕获到模型调用。新的桌面会话会自动记录精简的请求和响应预览。',
'trace.sessionTrace': '会话链路',
'trace.tokens': 'Tokens',
'trace.snapshotEmpty': 'Trace 快照为空。',
'trace.diagnosis': '诊断',
'trace.lastActivity': '最后活动',
'trace.errors': '错误',
'trace.pendingModels': '等待模型',
'trace.pendingTools': '等待工具',
'trace.focus': '关注点',
'trace.runTree': '运行树',
'trace.searchSpans': '搜索 Span',
'trace.noMatchingSpans': '没有匹配的 Span',
'trace.filter.all': '全部',
'trace.filter.llm': 'LLM',
'trace.filter.tools': '工具',
'trace.filter.errors': '错误',
'trace.sidechain': '子链',
'trace.thread': '对话线程',
'trace.threadStats': '{turns} 轮 · {spans} 个 Span · 实时轮询',
'trace.turnCount': '{count} 轮',
'trace.spanCount': '{count} 个 Span',
'trace.turnLabel': '第 {index} 轮',
'trace.stat.llm': 'LLM',
'trace.stat.tools': '工具',
'trace.stat.errors': '错误',
'trace.waitingForResult': '等待结果',
'trace.emptyMessage': '空消息',
'trace.inspectorAria': 'Trace Span 详情',
'trace.tab.overview': '概览',
'trace.tab.input': '输入',
'trace.tab.output': '输出',
'trace.tab.metadata': '元数据',
'trace.tab.raw': '原始',
'trace.kind': '类型',
'trace.started': '开始',
'trace.provider': 'Provider',
'trace.model': '模型',
'trace.notAvailable': '无',
'trace.sessionSummary': '会话摘要',
'trace.childSpans': '子 Span',
'trace.requestBody': '请求正文',
'trace.requestHeaders': '请求头',
'trace.toolArguments': '工具参数',
'trace.messageContent': '消息内容',
'trace.eventPayload': '事件负载',
'trace.noInput': '这个 Span 没有捕获输入。',
'trace.responseBody': '响应正文',
'trace.responseHeaders': '响应头',
'trace.toolResult': '工具结果',
'trace.toolResultPending': '工具结果还没有返回。',
'trace.noSeparateOutput': '这个 Span 没有单独的输出。',
'trace.spanMetadata': 'Span 元数据',
'trace.rawScope.span': '当前 Span',
'trace.rawScope.full': '完整 Trace',
'trace.rawSpanJson': '原始 Span JSON',
'trace.rawTraceJson': '原始 Trace JSON',
'trace.truncatedShort': '已截断',
'trace.emptyBodyShort': '空正文',
'trace.noData': '无数据',
'trace.spanFailed': '这个 Span 失败了。',
'trace.trace': 'Trace',
'trace.turns': '轮次',
'trace.llmCalls': 'LLM 调用',
'trace.toolCalls': '工具调用',
'trace.kind.session': '会话',
'trace.kind.turn': '轮次',
'trace.kind.llm': 'LLM 调用',
'trace.kind.tool': '工具调用',
'trace.kind.toolResult': '工具结果',
'trace.kind.message': '消息',
'trace.kind.event': '事件',
'trace.status.ok': '正常',
'trace.status.error': '错误',
'trace.status.pending': '等待中',
'trace.message.user': '用户消息',
'trace.message.assistant': '助手消息',
'trace.message.system': '系统消息',
'trace.message.toolRequest': '助手请求工具',
'trace.message.toolResult': '工具结果',
'trace.toolError': '工具错误',
'trace.modelCall': '模型调用',
'trace.modelCalls': '{count} 次模型调用',
'trace.sessionActivity': '会话活动',
'trace.emptyValue': '空',
'trace.diagnosis.modelError': '模型调用失败',
'trace.diagnosis.toolError': '工具执行失败',
'trace.diagnosis.eventError': '链路事件失败',
'trace.diagnosis.pendingModel': '正在等待模型响应',
'trace.diagnosis.pendingTool': '正在等待工具结果',
'trace.diagnosis.waitingForAgent': '用户消息尚未进入 agent 步骤',
'trace.diagnosis.empty': '没有可诊断事件',
'trace.diagnosis.healthy': '未发现阻塞点',
'trace.event.apiCallStarted': 'API 调用开始',
'trace.event.apiCallCompleted': 'API 调用完成',
'trace.event.apiCallFailed': 'API 调用失败',
'trace.event.responseCaptureFailed': '响应捕获失败',
'trace.event.upstreamFetchStarted': '上游请求开始',
'trace.event.upstreamFetchCompleted': '上游请求完成',
'trace.event.upstreamFetchFailed': '上游请求失败',
'trace.list.eyebrow': 'Agent Trace',
'trace.list.title': 'Trace 列表',
'trace.list.collecting': '正在收集',
'trace.list.paused': '已暂停',
'trace.list.settings': 'Trace 设置',
'trace.list.sessions': '会话',
'trace.list.session': 'Session',
'trace.list.actions': '操作',
'trace.list.searchPlaceholder': '搜索标题、Session ID 或项目路径',
'trace.list.emptyTitle': '还没有 Trace 记录',
'trace.list.emptyBody': '开启收集后,新的桌面会话会把模型调用链路写入本地 traces 目录。',
'trace.list.loadFailed': 'Trace 列表加载失败',
'trace.list.fileSize': '文件大小',
'trace.list.loadedCount': '已显示 {shown} / {total}',
'trace.list.loadMore': '加载更多',
// ─── 应用外壳 ──────────────────────────────────────
'app.serverFailed': '本地服务启动失败',

View File

@ -5,6 +5,7 @@ import type {
DesktopHostUnlisten,
NotificationPermissionState,
} from './types'
import { buildTraceWindowUrl } from '../traceLaunch'
const browserCapabilities: DesktopHostCapabilities = {
appMode: false,
@ -75,6 +76,15 @@ export const browserHost: DesktopHost = {
unsupported('Opening system file paths')
},
},
trace: {
async openWindow(sessionId) {
if (typeof window !== 'undefined') {
window.open(buildTraceWindowUrl(sessionId), '_blank', 'noopener,noreferrer')
return
}
unsupported('Opening trace windows')
},
},
dialogs: {
async open() {
unsupported('Native file dialogs')

View File

@ -60,6 +60,18 @@ describe('electron desktop host', () => {
})
})
it('opens dedicated trace windows through a narrow IPC channel', async () => {
const invoke = vi.fn().mockResolvedValue(undefined)
const host = createElectronHost({
invoke,
subscribe: vi.fn(),
})
await host.trace?.openWindow('session-123')
expect(invoke).toHaveBeenCalledWith(ELECTRON_IPC_CHANNELS.traceOpenWindow, 'session-123')
})
it('keeps event subscriptions behind named event channels', async () => {
const unlisten = vi.fn()
const subscribe = vi.fn().mockResolvedValue(unlisten)

View File

@ -91,6 +91,9 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
open: target => invoke(ELECTRON_IPC_CHANNELS.shellOpen, target),
openPath: path => invoke(ELECTRON_IPC_CHANNELS.shellOpenPath, path),
},
trace: {
openWindow: sessionId => invoke(ELECTRON_IPC_CHANNELS.traceOpenWindow, sessionId),
},
dialogs: {
open: options => invoke(ELECTRON_IPC_CHANNELS.dialogOpen, options),
save: options => invoke(ELECTRON_IPC_CHANNELS.dialogSave, options),

View File

@ -168,6 +168,9 @@ export type DesktopHost = {
open(target: string): Promise<void>
openPath(path: string): Promise<void>
}
trace?: {
openWindow(sessionId: string): Promise<void>
}
dialogs: {
open(options?: DialogOpenOptions): Promise<string | string[] | null>
save(options?: DialogSaveOptions): Promise<string | null>

View File

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { buildTraceWindowUrl, getTraceLaunchRequest } from './traceLaunch'
describe('trace launch URLs', () => {
it('parses dedicated trace window requests by session id', () => {
expect(getTraceLaunchRequest('?traceWindow=1&traceSessionId=session-123')).toEqual({
sessionId: 'session-123',
windowMode: true,
})
})
it('accepts sessionId as a shorter deep-link alias', () => {
expect(getTraceLaunchRequest('?sessionId=session-456')).toEqual({
sessionId: 'session-456',
windowMode: false,
})
})
it('builds a same-app trace window URL without dropping existing connection params', () => {
expect(buildTraceWindowUrl(
'session-789',
'http://127.0.0.1:5173/?serverUrl=http%3A%2F%2F127.0.0.1%3A3456',
)).toBe(
'http://127.0.0.1:5173/?serverUrl=http%3A%2F%2F127.0.0.1%3A3456&traceWindow=1&traceSessionId=session-789',
)
})
})

View File

@ -0,0 +1,30 @@
export type TraceLaunchRequest = {
sessionId: string | null
windowMode: boolean
}
function normalizeTraceSessionId(value: string | null): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
function isTruthyParam(value: string | null): boolean {
return value === '1' || value === 'true' || value === 'yes'
}
export function getTraceLaunchRequest(search = window.location.search): TraceLaunchRequest {
const params = new URLSearchParams(search)
return {
sessionId: normalizeTraceSessionId(
params.get('traceSessionId') ?? params.get('sessionId'),
),
windowMode: isTruthyParam(params.get('traceWindow')),
}
}
export function buildTraceWindowUrl(sessionId: string, currentHref = window.location.href): string {
const url = new URL(currentHref)
url.searchParams.set('traceWindow', '1')
url.searchParams.set('traceSessionId', sessionId)
return url.toString()
}

View File

@ -0,0 +1,185 @@
import { describe, expect, it } from 'vitest'
import { buildTraceViewModel } from './traceViewModel'
import type { MessageEntry } from '../types/session'
import type { TraceSession } from '../types/trace'
const trace: TraceSession = {
sessionId: 'session-live',
session: null,
summary: {
apiCalls: 2,
failedCalls: 0,
totalDurationMs: 1700,
totalInputTokens: 12,
totalOutputTokens: 18,
models: [{ model: 'gpt-5.5', calls: 2 }],
updatedAt: '2026-06-09T10:00:04.000Z',
},
calls: [
{
id: 'call-1',
sessionId: 'session-live',
source: 'anthropic',
provider: { id: 'provider-sub2api', name: 'Sub2API-ChatGPT', format: 'anthropic' },
model: 'gpt-5.5',
startedAt: '2026-06-09T10:00:01.000Z',
completedAt: '2026-06-09T10:00:02.000Z',
durationMs: 1000,
request: {
method: 'POST',
url: 'https://sub2api.example/v1/messages',
headers: {},
body: { contentType: 'json', bytes: 20, sha256: 'a', preview: '{"model":"gpt-5.5"}', truncated: false },
},
response: {
status: 200,
headers: {},
body: { contentType: 'json', bytes: 11, sha256: 'b', preview: '{"ok":true}', truncated: false },
},
},
{
id: 'call-2',
sessionId: 'session-live',
source: 'anthropic',
provider: { id: 'provider-sub2api', name: 'Sub2API-ChatGPT', format: 'anthropic' },
model: 'gpt-5.5',
startedAt: '2026-06-09T10:00:04.000Z',
completedAt: '2026-06-09T10:00:04.700Z',
durationMs: 700,
request: {
method: 'POST',
url: 'https://sub2api.example/v1/messages',
headers: {},
body: { contentType: 'json', bytes: 20, sha256: 'c', preview: '{"model":"gpt-5.5"}', truncated: false },
},
response: {
status: 200,
headers: {},
body: { contentType: 'json', bytes: 11, sha256: 'd', preview: '{"ok":true}', truncated: false },
},
},
],
events: [
{
id: 'event-1',
sessionId: 'session-live',
callId: 'call-1',
source: 'anthropic',
provider: { id: 'provider-sub2api', name: 'Sub2API-ChatGPT', format: 'anthropic' },
model: 'gpt-5.5',
timestamp: '2026-06-09T10:00:01.100Z',
phase: 'api_call_started',
severity: 'info',
metadata: { url: 'https://sub2api.example/v1/messages' },
},
],
}
const messages: MessageEntry[] = [
{
id: 'user-1',
type: 'user',
content: 'Run ls and summarize the result',
timestamp: '2026-06-09T10:00:00.000Z',
},
{
id: 'assistant-1',
type: 'tool_use',
content: [
{ type: 'text', text: 'I will inspect the directory.' },
{ type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'ls -la /tmp' } },
],
timestamp: '2026-06-09T10:00:02.000Z',
},
{
id: 'result-1',
type: 'tool_result',
content: [
{ type: 'tool_result', tool_use_id: 'tool-1', content: 'total 8', is_error: false },
],
timestamp: '2026-06-09T10:00:03.000Z',
},
]
describe('traceViewModel', () => {
it('builds a turn-centered tree with llm and paired tool spans', () => {
const viewModel = buildTraceViewModel(trace, messages)
expect(viewModel.turns).toHaveLength(1)
expect(viewModel.spansById.get('turn:0')?.childIds).toEqual(expect.arrayContaining([
'message:user-1',
'message:assistant-1',
'tool:tool-1',
'llm:call-1',
'llm:call-2',
]))
const tool = viewModel.spansById.get('tool:tool-1')
expect(tool).toMatchObject({
kind: 'tool',
status: 'ok',
title: 'Bash',
subtitle: 'ls -la /tmp',
})
expect(tool?.childIds[0]).toMatch(/^tool_result:/)
expect(viewModel.spansById.get(tool?.childIds[0] ?? '')).toMatchObject({
kind: 'tool_result',
status: 'ok',
output: 'total 8',
})
expect(viewModel.spansById.get('event:event-1')).toMatchObject({
kind: 'event',
parentId: 'llm:call-1',
status: 'ok',
})
expect(viewModel.spansById.get('llm:call-1')?.childIds).toContain('event:event-1')
})
it('marks pending tool spans when a result has not arrived', () => {
const viewModel = buildTraceViewModel(trace, messages.slice(0, 2))
expect(viewModel.spansById.get('tool:tool-1')).toMatchObject({ status: 'pending' })
expect(viewModel.spansById.get('turn:0')).toMatchObject({ status: 'pending' })
expect(viewModel.diagnosis).toMatchObject({
status: 'attention',
reason: 'pending_tool',
focusSpanId: 'tool:tool-1',
pendingToolCalls: 1,
})
})
it('diagnoses event-only errors as blocked', () => {
const viewModel = buildTraceViewModel({
sessionId: 'session-event-error',
session: null,
summary: {
apiCalls: 0,
failedCalls: 0,
totalDurationMs: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
models: [],
updatedAt: null,
},
calls: [],
events: [{
id: 'event-failed',
sessionId: 'session-event-error',
timestamp: '2026-06-09T10:00:02.000Z',
phase: 'upstream_fetch_failed',
severity: 'error',
message: 'network down',
}],
}, [])
expect(viewModel.spansById.get('event:event-failed')).toMatchObject({
kind: 'event',
status: 'error',
title: 'Upstream Fetch Failed',
})
expect(viewModel.diagnosis).toMatchObject({
status: 'blocked',
reason: 'event_error',
focusSpanId: 'event:event-failed',
})
})
})

View File

@ -0,0 +1,634 @@
import type { MessageEntry } from '../types/session'
import type { TraceCallRecord, TraceEventRecord, TraceSession as TraceSessionData } from '../types/trace'
export type TraceSpanKind = 'session' | 'turn' | 'message' | 'llm' | 'tool' | 'tool_result' | 'event'
export type TraceSpanStatus = 'ok' | 'error' | 'pending'
export type TraceSpan = {
id: string
parentId: string | null
childIds: string[]
kind: TraceSpanKind
status: TraceSpanStatus
title: string
subtitle: string
timestamp: string
completedAt?: string
durationMs?: number
turnIndex?: number
message?: MessageEntry
call?: TraceCallRecord
event?: TraceEventRecord
toolUseId?: string
toolName?: string
input?: unknown
output?: unknown
isSidechain?: boolean
raw: unknown
}
export type TraceDiagnosisReason =
| 'empty'
| 'model_error'
| 'tool_error'
| 'event_error'
| 'pending_model'
| 'pending_tool'
| 'waiting_for_agent'
| 'healthy'
export type TraceDiagnosis = {
status: 'empty' | 'healthy' | 'attention' | 'blocked'
reason: TraceDiagnosisReason
focusSpanId?: string
evidenceSpanIds: string[]
lastActivityAt: string
errorCount: number
pendingModelCalls: number
pendingToolCalls: number
modelCalls: number
toolCalls: number
}
export type TraceTurn = {
id: string
index: number
title: string
timestamp: string
spanIds: string[]
userSpanId?: string
}
export type TraceViewModel = {
rootId: string
spans: TraceSpan[]
spansById: Map<string, TraceSpan>
turns: TraceTurn[]
orderedSpanIds: string[]
diagnosis: TraceDiagnosis
fullRaw: unknown
}
type ToolUseBlock = {
type: 'tool_use'
id?: string
name?: string
input?: unknown
}
type ToolResultBlock = {
type: 'tool_result'
tool_use_id?: string
content?: unknown
is_error?: boolean
}
type TextBlock = {
type?: string
text?: string
}
type MutableSpan = Omit<TraceSpan, 'childIds'> & { childIds: string[] }
export function buildTraceViewModel(
trace: TraceSessionData,
messages: MessageEntry[],
): TraceViewModel {
const spans = new Map<string, MutableSpan>()
const turns: TraceTurn[] = []
const rootId = 'session:root'
const traceEvents = trace.events ?? []
const fallbackTimestamp = earliestTimestamp(trace.calls, messages, traceEvents) ?? new Date().toISOString()
const hasPendingCalls = trace.calls.some((call) => getCallStatus(call) === 'pending')
const hasTraceErrors = trace.summary.failedCalls > 0 || traceEvents.some((event) => event.severity === 'error')
addSpan(spans, {
id: rootId,
parentId: null,
kind: 'session',
status: hasTraceErrors ? 'error' : hasPendingCalls ? 'pending' : 'ok',
title: trace.session?.title ?? trace.sessionId,
subtitle: `${trace.summary.apiCalls} model calls`,
timestamp: fallbackTimestamp,
completedAt: trace.summary.updatedAt ?? undefined,
durationMs: trace.summary.totalDurationMs,
raw: {
sessionId: trace.sessionId,
summary: trace.summary,
session: trace.session,
events: traceEvents,
},
})
const userTurnStarts = messages.filter((message) => message.type === 'user' && !hasToolResultBlocks(message.content))
if (userTurnStarts.length === 0) {
turns.push(createTurn(0, fallbackTimestamp, 'Session activity'))
} else {
userTurnStarts.forEach((message, index) => {
turns.push(createTurn(index, message.timestamp, createTurnTitle(index, message.content)))
})
}
for (const turn of turns) {
addSpan(spans, {
id: turn.id,
parentId: rootId,
kind: 'turn',
status: 'ok',
title: turn.title,
subtitle: `Turn ${turn.index + 1}`,
timestamp: turn.timestamp,
turnIndex: turn.index,
raw: { index: turn.index, timestamp: turn.timestamp, title: turn.title },
})
}
const toolSpanIds = new Map<string, string>()
const resultBlocksByToolUseId = new Map<string, ToolResultBlock[]>()
const deferredToolResultSpans: Array<{ parentId: string; message: MessageEntry; block: ToolResultBlock }> = []
for (const message of messages) {
const turn = findTurnForTimestamp(turns, message.timestamp)
const turnId = turn.id
if (message.type === 'tool_result') {
const resultBlocks = extractToolResultBlocks(message.content)
if (resultBlocks.length === 0) {
const spanId = `message:${message.id}`
addMessageSpan(spans, spanId, turnId, turn.index, message)
turn.spanIds.push(spanId)
continue
}
for (const block of resultBlocks) {
if (block.tool_use_id) {
const existing = resultBlocksByToolUseId.get(block.tool_use_id) ?? []
existing.push(block)
resultBlocksByToolUseId.set(block.tool_use_id, existing)
}
const toolParentId = block.tool_use_id ? toolSpanIds.get(block.tool_use_id) : undefined
if (toolParentId) {
const spanId = `tool_result:${message.id}:${block.tool_use_id ?? resultBlocks.indexOf(block)}`
addToolResultSpan(spans, spanId, toolParentId, turn.index, message, block)
turn.spanIds.push(spanId)
} else {
deferredToolResultSpans.push({ parentId: turnId, message, block })
}
}
continue
}
const text = extractTextContent(message.content)
if (message.type !== 'tool_use' || text.trim()) {
const spanId = `message:${message.id}`
addMessageSpan(spans, spanId, turnId, turn.index, message)
turn.spanIds.push(spanId)
if (message.type === 'user' && !hasToolResultBlocks(message.content)) {
turn.userSpanId = spanId
}
}
if (message.type === 'tool_use') {
for (const block of extractToolUseBlocks(message.content)) {
const toolId = block.id ?? `${message.id}:${block.name ?? 'tool'}`
const parentToolId = message.parentToolUseId ? toolSpanIds.get(message.parentToolUseId) : undefined
const parentId = parentToolId ?? turnId
const spanId = `tool:${toolId}`
toolSpanIds.set(toolId, spanId)
const resultBlocks = block.id ? resultBlocksByToolUseId.get(block.id) ?? [] : []
addSpan(spans, {
id: spanId,
parentId,
kind: 'tool',
status: resultBlocks.some((result) => result.is_error) ? 'error' : resultBlocks.length > 0 ? 'ok' : 'pending',
title: block.name ?? 'Tool call',
subtitle: summarizeToolInput(block.input),
timestamp: message.timestamp,
turnIndex: turn.index,
message,
toolUseId: toolId,
toolName: block.name,
input: block.input,
output: resultBlocks.map((result) => result.content),
isSidechain: message.isSidechain,
raw: block,
})
turn.spanIds.push(spanId)
}
}
}
for (const item of deferredToolResultSpans) {
const turn = findTurnForTimestamp(turns, item.message.timestamp)
const spanId = `tool_result:${item.message.id}:${item.block.tool_use_id ?? turn.spanIds.length}`
addToolResultSpan(spans, spanId, item.parentId, turn.index, item.message, item.block)
turn.spanIds.push(spanId)
}
for (const call of trace.calls) {
const turn = findTurnForTimestamp(turns, call.startedAt)
const spanId = `llm:${call.id}`
addSpan(spans, {
id: spanId,
parentId: turn.id,
kind: 'llm',
status: getCallStatus(call),
title: call.model ?? call.provider?.name ?? 'Model call',
subtitle: call.provider?.name ?? call.source,
timestamp: call.startedAt,
completedAt: call.completedAt,
durationMs: call.durationMs,
turnIndex: turn.index,
call,
raw: call,
})
turn.spanIds.push(spanId)
}
for (const event of traceEvents) {
const turn = findTurnForTimestamp(turns, event.timestamp)
const callParentId = event.callId ? `llm:${event.callId}` : undefined
const parentId = callParentId && spans.has(callParentId) ? callParentId : turn.id
const spanId = `event:${event.id}`
addSpan(spans, {
id: spanId,
parentId,
kind: 'event',
status: getEventStatus(event),
title: event.title ?? formatTraceEventPhase(event.phase),
subtitle: event.message ?? previewTraceValue(event.metadata),
timestamp: event.timestamp,
turnIndex: turn.index,
event,
raw: event,
})
turn.spanIds.push(spanId)
}
for (const turn of turns) {
const childStatuses = turn.spanIds
.map((spanId) => spans.get(spanId)?.status)
.filter(Boolean)
const turnSpan = spans.get(turn.id)
if (turnSpan) {
turnSpan.status = childStatuses.includes('error')
? 'error'
: childStatuses.includes('pending')
? 'pending'
: 'ok'
turnSpan.subtitle = `${turn.spanIds.length} spans`
}
}
const orderedSpanIds = orderSpans(spans, rootId)
const spanList = orderedSpanIds.map((id) => spans.get(id)).filter(Boolean) as TraceSpan[]
return {
rootId,
spans: spanList,
spansById: new Map(spanList.map((span) => [span.id, span])),
turns,
orderedSpanIds,
diagnosis: buildDiagnosis(spanList, turns, rootId, fallbackTimestamp),
fullRaw: {
session: trace.session,
summary: trace.summary,
calls: trace.calls,
events: traceEvents,
messages,
},
}
}
function buildDiagnosis(
spans: TraceSpan[],
turns: TraceTurn[],
rootId: string,
fallbackTimestamp: string,
): TraceDiagnosis {
const meaningfulSpans = spans.filter((span) => span.id !== rootId)
const errorSpans = meaningfulSpans.filter((span) => span.status === 'error')
const pendingModels = meaningfulSpans.filter((span) => span.kind === 'llm' && span.status === 'pending')
const pendingTools = meaningfulSpans.filter((span) => span.kind === 'tool' && span.status === 'pending')
const toolErrors = errorSpans.filter((span) => span.kind === 'tool' || span.kind === 'tool_result')
const modelErrors = errorSpans.filter((span) => span.kind === 'llm')
const eventErrors = errorSpans.filter((span) => span.kind === 'event')
const lastSpan = meaningfulSpans
.slice()
.sort((a, b) => compareSpanTime(a, b))
.at(-1)
const lastTurn = turns.at(-1)
const lastTurnSpans = lastTurn
? lastTurn.spanIds.map((spanId) => spans.find((span) => span.id === spanId)).filter(Boolean) as TraceSpan[]
: []
const lastTurnHasUser = lastTurnSpans.some((span) => span.message?.type === 'user')
const lastTurnHasAgentWork = lastTurnSpans.some((span) =>
span.kind === 'llm' ||
span.kind === 'tool' ||
span.kind === 'tool_result' ||
span.message?.type === 'assistant' ||
span.message?.type === 'tool_use'
)
if (meaningfulSpans.length === 0) {
return {
status: 'empty',
reason: 'empty',
evidenceSpanIds: [],
lastActivityAt: fallbackTimestamp,
errorCount: 0,
pendingModelCalls: 0,
pendingToolCalls: 0,
modelCalls: 0,
toolCalls: 0,
}
}
if (modelErrors.length > 0) {
return createDiagnosis('blocked', 'model_error', modelErrors, spans, fallbackTimestamp)
}
if (toolErrors.length > 0) {
return createDiagnosis('blocked', 'tool_error', toolErrors, spans, fallbackTimestamp)
}
if (eventErrors.length > 0) {
return createDiagnosis('blocked', 'event_error', eventErrors, spans, fallbackTimestamp)
}
if (pendingModels.length > 0) {
return createDiagnosis('attention', 'pending_model', pendingModels, spans, fallbackTimestamp)
}
if (pendingTools.length > 0) {
return createDiagnosis('attention', 'pending_tool', pendingTools, spans, fallbackTimestamp)
}
if (lastTurnHasUser && !lastTurnHasAgentWork) {
const evidence = lastTurnSpans.length > 0 ? lastTurnSpans : lastSpan ? [lastSpan] : []
return createDiagnosis('attention', 'waiting_for_agent', evidence, spans, fallbackTimestamp)
}
return createDiagnosis('healthy', 'healthy', lastSpan ? [lastSpan] : [], spans, fallbackTimestamp)
}
function createDiagnosis(
status: TraceDiagnosis['status'],
reason: TraceDiagnosisReason,
evidence: TraceSpan[],
spans: TraceSpan[],
fallbackTimestamp: string,
): TraceDiagnosis {
const errors = spans.filter((span) => span.status === 'error')
const pendingModels = spans.filter((span) => span.kind === 'llm' && span.status === 'pending')
const pendingTools = spans.filter((span) => span.kind === 'tool' && span.status === 'pending')
const lastActivityAt = spans
.slice()
.sort((a, b) => compareSpanTime(a, b))
.at(-1)?.timestamp ?? fallbackTimestamp
return {
status,
reason,
focusSpanId: evidence[0]?.id,
evidenceSpanIds: evidence.map((span) => span.id),
lastActivityAt,
errorCount: errors.length,
pendingModelCalls: pendingModels.length,
pendingToolCalls: pendingTools.length,
modelCalls: spans.filter((span) => span.kind === 'llm').length,
toolCalls: spans.filter((span) => span.kind === 'tool').length,
}
}
export function extractTextContent(content: unknown): string {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return formatUnknown(content)
return content
.flatMap((block) => {
if (!block || typeof block !== 'object') return []
const record = block as TextBlock
if (typeof record.text === 'string') return [record.text]
if ('content' in record && typeof (record as { content?: unknown }).content === 'string') {
return [(record as { content: string }).content]
}
return []
})
.join('\n')
}
export function previewTraceValue(value: unknown, maxChars = 180): string {
const text = extractTextContent(value).replace(/\s+/g, ' ').trim()
if (!text) return 'empty'
return text.length > maxChars ? `${text.slice(0, maxChars)}...` : text
}
export function formatTraceJson(value: unknown): string {
if (typeof value === 'string') {
const parsed = parseJson(value)
return parsed === null ? value : JSON.stringify(parsed, null, 2)
}
return formatUnknown(value)
}
export function getTraceValueLanguage(value: unknown, fallback: 'json' | 'text' = 'json'): 'json' | 'text' {
if (typeof value !== 'string') return 'json'
return parseJson(value) === null ? fallback : 'json'
}
function addMessageSpan(
spans: Map<string, MutableSpan>,
id: string,
parentId: string,
turnIndex: number,
message: MessageEntry,
) {
addSpan(spans, {
id,
parentId,
kind: 'message',
status: 'ok',
title: titleForMessage(message),
subtitle: previewTraceValue(message.content),
timestamp: message.timestamp,
turnIndex,
message,
raw: message,
})
}
function addToolResultSpan(
spans: Map<string, MutableSpan>,
id: string,
parentId: string,
turnIndex: number,
message: MessageEntry,
block: ToolResultBlock,
) {
addSpan(spans, {
id,
parentId,
kind: 'tool_result',
status: block.is_error ? 'error' : 'ok',
title: block.is_error ? 'Tool error' : 'Tool result',
subtitle: previewTraceValue(block.content),
timestamp: message.timestamp,
turnIndex,
message,
toolUseId: block.tool_use_id,
output: block.content,
raw: block,
})
const parent = spans.get(parentId)
if (parent?.kind === 'tool') {
parent.status = block.is_error ? 'error' : 'ok'
const existingOutput = Array.isArray(parent.output) ? parent.output : parent.output === undefined ? [] : [parent.output]
parent.output = [...existingOutput, block.content]
}
}
function addSpan(spans: Map<string, MutableSpan>, span: Omit<TraceSpan, 'childIds'>) {
if (spans.has(span.id)) return
spans.set(span.id, { ...span, childIds: [] })
if (span.parentId) {
const parent = spans.get(span.parentId)
if (parent && !parent.childIds.includes(span.id)) {
parent.childIds.push(span.id)
}
}
}
function orderSpans(spans: Map<string, MutableSpan>, rootId: string): string[] {
const result: string[] = []
const visit = (id: string) => {
const span = spans.get(id)
if (!span) return
result.push(id)
span.childIds.sort((a, b) => compareSpanTime(spans.get(a), spans.get(b)))
for (const childId of span.childIds) visit(childId)
}
visit(rootId)
return result
}
function compareSpanTime(a?: MutableSpan, b?: MutableSpan): number {
if (!a || !b) return 0
const diff = new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
if (Number.isFinite(diff) && diff !== 0) return diff
return a.id.localeCompare(b.id)
}
function createTurn(index: number, timestamp: string, title: string): TraceTurn {
return {
id: `turn:${index}`,
index,
title,
timestamp,
spanIds: [],
}
}
function createTurnTitle(index: number, content: unknown): string {
const preview = previewTraceValue(content, 54)
return preview === 'empty' ? `Turn ${index + 1}` : preview
}
function findTurnForTimestamp(turns: TraceTurn[], timestamp: string): TraceTurn {
const time = new Date(timestamp).getTime()
if (!Number.isFinite(time)) return turns[0]!
let current = turns[0]!
for (const turn of turns) {
const turnTime = new Date(turn.timestamp).getTime()
if (Number.isFinite(turnTime) && turnTime <= time) {
current = turn
}
}
return current
}
function earliestTimestamp(
calls: TraceCallRecord[],
messages: MessageEntry[],
events: TraceEventRecord[] = [],
): string | null {
const timestamps = [
...calls.map((call) => call.startedAt),
...messages.map((message) => message.timestamp),
...events.map((event) => event.timestamp),
]
.map((value) => ({ value, time: new Date(value).getTime() }))
.filter((entry) => Number.isFinite(entry.time))
.sort((a, b) => a.time - b.time)
return timestamps[0]?.value ?? null
}
function hasToolResultBlocks(content: unknown): boolean {
return extractToolResultBlocks(content).length > 0
}
function extractToolUseBlocks(content: unknown): ToolUseBlock[] {
if (!Array.isArray(content)) return []
return content.filter((block): block is ToolUseBlock =>
!!block &&
typeof block === 'object' &&
(block as { type?: unknown }).type === 'tool_use'
)
}
function extractToolResultBlocks(content: unknown): ToolResultBlock[] {
if (!Array.isArray(content)) return []
return content.filter((block): block is ToolResultBlock =>
!!block &&
typeof block === 'object' &&
(block as { type?: unknown }).type === 'tool_result'
)
}
function titleForMessage(message: MessageEntry): string {
switch (message.type) {
case 'user': return 'User message'
case 'assistant': return message.model ? `Assistant · ${message.model}` : 'Assistant message'
case 'system': return 'System message'
case 'tool_use': return 'Assistant tool request'
case 'tool_result': return 'Tool result'
default: return message.type
}
}
function summarizeToolInput(input: unknown): string {
if (input && typeof input === 'object' && !Array.isArray(input)) {
const record = input as Record<string, unknown>
const primary = record.command ?? record.file_path ?? record.path ?? record.query ?? record.description
if (typeof primary === 'string' && primary.trim()) return primary.trim()
}
return previewTraceValue(input, 140)
}
function getCallStatus(call: TraceCallRecord): TraceSpanStatus {
if (call.status === 'error' || call.error || (call.response?.status ?? 200) >= 400) return 'error'
if (call.status === 'pending' || !call.response) return 'pending'
return 'ok'
}
function getEventStatus(event: TraceEventRecord): TraceSpanStatus {
return event.severity === 'error' ? 'error' : 'ok'
}
function formatTraceEventPhase(phase: string): string {
return phase
.split(/[_\s-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
function formatUnknown(value: unknown): string {
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
function parseJson(value: string): unknown | null {
const trimmed = value.trim()
if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return null
try {
return JSON.parse(trimmed)
} catch {
return null
}
}

View File

@ -186,6 +186,67 @@ describe('ActiveSession task polling', () => {
expect(screen.getByTestId('chat-input')).toHaveAttribute('data-variant', 'default')
})
it('opens the current session trace in a separate live window', () => {
const sessionId = 'trace-window-session'
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Trace Window 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: 'Trace Window Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'hello', timestamp: 1 }],
chatState: 'idle',
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,
},
},
})
try {
render(<ActiveSession />)
fireEvent.click(screen.getByLabelText(/独立|separate/i))
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining(`traceSessionId=${sessionId}`),
'_blank',
'noopener,noreferrer',
)
expect(openSpy.mock.calls[0]?.[0]).toContain('traceWindow=1')
} finally {
openSpy.mockRestore()
}
})
it('renders the current goal as a lightweight header strip without a page-level panel', () => {
const sessionId = 'goal-visible-session'

View File

@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Target } from 'lucide-react'
import { ExternalLink, RadioTower, Target } from 'lucide-react'
import {
SCHEDULED_TAB_ID,
SETTINGS_TAB_ID,
TERMINAL_TAB_PREFIX,
TRACE_TAB_PREFIX,
useTabStore,
type TabType,
} from '../stores/tabStore'
@ -31,6 +32,8 @@ import type { ActiveGoalState } from '../types/chat'
import { useMobileViewport } from '../hooks/useMobileViewport'
import { isDesktopRuntime } from '../lib/desktopRuntime'
import { publicAssetPath } from '../lib/publicAsset'
import { getDesktopHost } from '../lib/desktopHost'
import { buildTraceWindowUrl } from '../lib/traceLaunch'
const TASK_POLL_INTERVAL_MS = 1000
const WORKSPACE_RESIZE_STEP = 32
@ -44,7 +47,8 @@ function isSessionTabState(activeTabId: string | null, activeTabType: TabType |
if (activeTabType) return false
return activeTabId !== SETTINGS_TAB_ID &&
activeTabId !== SCHEDULED_TAB_ID &&
!activeTabId.startsWith(TERMINAL_TAB_PREFIX)
!activeTabId.startsWith(TERMINAL_TAB_PREFIX) &&
!activeTabId.startsWith(TRACE_TAB_PREFIX)
}
function getSessionTerminalCwd(session: SessionListItem | undefined) {
@ -360,6 +364,22 @@ export function ActiveSession() {
return t('session.timeDays', { n: Math.floor(diff / 86400000) })
}, [session?.modifiedAt, t])
const openTrace = () => {
if (!activeTabId) return
const title = session?.title || t('session.untitled')
useTabStore.getState().openTraceTab(activeTabId, `${t('trace.title')}: ${title}`)
}
const openTraceWindow = () => {
if (!activeTabId) return
const host = getDesktopHost()
if (host.trace) {
void host.trace.openWindow(activeTabId)
return
}
window.open(buildTraceWindowUrl(activeTabId), '_blank', 'noopener,noreferrer')
}
if (!activeTabId) return null
return (
@ -450,15 +470,36 @@ export function ActiveSession() {
}
>
<div className={showRightPanel ? 'min-w-0 flex-1' : 'mx-auto w-full max-w-[860px] min-w-0'}>
<h1
className={
showRightPanel
? 'truncate text-[15px] font-bold font-headline leading-tight text-on-surface'
: 'text-lg font-bold font-headline text-on-surface leading-tight'
}
>
{session?.title || t('session.untitled')}
</h1>
<div className="flex min-w-0 items-center gap-3">
<h1
className={
showRightPanel
? 'min-w-0 flex-1 truncate text-[15px] font-bold font-headline leading-tight text-on-surface'
: 'min-w-0 flex-1 text-lg font-bold font-headline text-on-surface leading-tight'
}
>
{session?.title || t('session.untitled')}
</h1>
<button
type="button"
aria-label={t('trace.open')}
title={t('trace.open')}
onClick={openTrace}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-[8px] border border-[var(--color-border)] px-2.5 text-xs font-semibold text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-focus)] hover:text-[var(--color-text-primary)] active:scale-[0.98]"
>
<RadioTower size={14} strokeWidth={2} aria-hidden="true" />
<span className={showRightPanel ? 'sr-only' : 'hidden sm:inline'}>{t('trace.open')}</span>
</button>
<button
type="button"
aria-label={t('trace.openWindow')}
title={t('trace.openWindow')}
onClick={openTraceWindow}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] border border-[var(--color-border)] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-focus)] hover:text-[var(--color-text-primary)] active:scale-[0.98]"
>
<ExternalLink size={14} strokeWidth={2} aria-hidden="true" />
</button>
</div>
<div
className={
showRightPanel

View File

@ -1456,6 +1456,8 @@ export function GeneralSettings() {
setWebSearch,
network,
setNetwork,
traceCapture,
setTraceCaptureEnabled,
responseLanguage,
setResponseLanguage,
appMode,
@ -2089,6 +2091,34 @@ export function GeneralSettings() {
</label>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.traceTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.traceDescription')}</p>
<label className="flex items-start gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 cursor-pointer hover:border-[var(--color-border-focus)] transition-colors">
<input
type="checkbox"
aria-label={t('settings.general.traceEnabled')}
checked={traceCapture.enabled}
onChange={(e) => void setTraceCaptureEnabled(e.target.checked)}
className="peer sr-only"
/>
<SettingsCheckboxMark checked={traceCapture.enabled} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.traceEnabled')}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
{traceCapture.enabled ? t('settings.general.traceHintOn') : t('settings.general.traceHintOff')}
</div>
{traceCapture.storageDir && (
<div className="mt-2 truncate rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-[11px] text-[var(--color-text-secondary)]">
{traceCapture.storageDir}
</div>
)}
</div>
</label>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.notificationsTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.notificationsDescription')}</p>

View File

@ -0,0 +1,132 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TraceList } from './TraceList'
import { tracesApi } from '../api/traces'
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
import { useSettingsStore } from '../stores/settingsStore'
import type { TraceSessionList } from '../types/trace'
vi.mock('../api/traces', () => ({
tracesApi: {
list: vi.fn(),
},
}))
const traceList: TraceSessionList = {
total: 1,
storageDir: '/tmp/cc-haha/traces',
settings: {
enabled: true,
storageDir: '/tmp/cc-haha/traces',
},
traces: [{
sessionId: 'session-trace-list',
session: {
id: 'session-trace-list',
title: 'Debug stuck agent',
projectPath: '/tmp/project',
workDir: '/tmp/project',
},
summary: {
apiCalls: 3,
failedCalls: 1,
totalDurationMs: 4715,
totalInputTokens: 0,
totalOutputTokens: 0,
models: [{ model: 'gpt-5.5', calls: 2 }],
updatedAt: '2026-06-09T15:03:40.010Z',
},
fileSize: 2048,
fileUpdatedAt: '2026-06-09T15:03:40.010Z',
}],
}
const secondTraceList: TraceSessionList = {
...traceList,
traces: [{
...traceList.traces[0]!,
sessionId: 'session-trace-second-page',
session: {
id: 'session-trace-second-page',
title: 'Second trace session',
projectPath: '/tmp/second-project',
workDir: '/tmp/second-project',
},
summary: {
...traceList.traces[0]!.summary,
apiCalls: 1,
failedCalls: 0,
models: [{ model: 'gpt-5.5', calls: 1 }],
},
}],
}
describe('TraceList', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
useTabStore.setState({ tabs: [], activeTabId: null })
vi.mocked(tracesApi.list).mockResolvedValue(traceList)
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
useTabStore.setState({ tabs: [], activeTabId: null })
useSettingsStore.setState({ locale: 'en' })
})
it('renders trace session rows and opens a session trace tab', async () => {
render(<TraceList />)
expect(await screen.findByText('Debug stuck agent')).toBeInTheDocument()
expect(screen.getByText('/tmp/cc-haha/traces')).toBeInTheDocument()
expect(screen.getByText('gpt-5.5 x2')).toBeInTheDocument()
expect(tracesApi.list).toHaveBeenCalledWith({ limit: 50, offset: 0, query: '' })
fireEvent.click(screen.getAllByRole('button', { name: 'Trace' })[0]!)
expect(useTabStore.getState().activeTabId).toBe('__trace__session-trace-list')
expect(useTabStore.getState().tabs.find((tab) => tab.type === 'trace')?.traceSessionId).toBe('session-trace-list')
})
it('opens General settings from the trace settings button', async () => {
render(<TraceList />)
fireEvent.click(await screen.findByRole('button', { name: 'Trace settings' }))
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
expect(useTabStore.getState().tabs.find((tab) => tab.sessionId === SETTINGS_TAB_ID)?.type).toBe('settings')
})
it('loads additional trace pages instead of fetching all rows at once', async () => {
vi.mocked(tracesApi.list)
.mockResolvedValueOnce({ ...traceList, total: 2 })
.mockResolvedValueOnce({ ...secondTraceList, total: 2 })
render(<TraceList />)
expect(await screen.findByText('Debug stuck agent')).toBeInTheDocument()
expect(screen.getByText('Showing 1 of 2')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
expect(await screen.findByText('Second trace session')).toBeInTheDocument()
expect(screen.getByText('Showing 2 of 2')).toBeInTheDocument()
expect(tracesApi.list).toHaveBeenNthCalledWith(1, { limit: 50, offset: 0, query: '' })
expect(tracesApi.list).toHaveBeenNthCalledWith(2, { limit: 50, offset: 1, query: '' })
})
it('sends title search text to the trace list API', async () => {
render(<TraceList />)
await screen.findByText('Debug stuck agent')
fireEvent.change(screen.getByPlaceholderText('Search title, session ID, or project path'), {
target: { value: 'stuck agent' },
})
await waitFor(() => {
expect(tracesApi.list).toHaveBeenLastCalledWith({ limit: 50, offset: 0, query: 'stuck agent' })
})
})
})

View File

@ -0,0 +1,335 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { AlertTriangle, CheckCircle2, ExternalLink, RefreshCw, Search, Workflow } from 'lucide-react'
import { tracesApi } from '../api/traces'
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore'
import { useTranslation } from '../i18n'
import { Button } from '../components/shared/Button'
import { formatBytes } from '../lib/formatBytes'
import { getDesktopHost } from '../lib/desktopHost'
import type { TraceSessionList, TraceSessionListItem } from '../types/trace'
type TraceListState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; data: TraceSessionList }
const POLL_MS = 5_000
const PAGE_SIZE = 50
const SEARCH_DEBOUNCE_MS = 250
export function TraceList() {
const t = useTranslation()
const [state, setState] = useState<TraceListState>({ status: 'loading' })
const [queryInput, setQueryInput] = useState('')
const [query, setQuery] = useState('')
const [isLoadingMore, setIsLoadingMore] = useState(false)
const host = getDesktopHost()
useEffect(() => {
const timer = window.setTimeout(() => {
setQuery(queryInput.trim())
}, SEARCH_DEBOUNCE_MS)
return () => window.clearTimeout(timer)
}, [queryInput])
const load = useCallback(async (options?: {
append?: boolean
limit?: number
offset?: number
silent?: boolean
}) => {
const append = options?.append === true
const offset = options?.offset ?? 0
const limit = options?.limit ?? PAGE_SIZE
try {
if (append) {
setIsLoadingMore(true)
} else if (!options?.silent) {
setState({ status: 'loading' })
}
const data = await tracesApi.list({ limit, offset, query })
setState((previous) => {
if (!append || previous.status !== 'ready') {
return { status: 'ready', data }
}
return {
status: 'ready',
data: {
...data,
traces: [...previous.data.traces, ...data.traces],
},
}
})
} catch (error) {
setState({
status: 'error',
message: error instanceof Error ? error.message : t('trace.list.loadFailed'),
})
} finally {
if (append) setIsLoadingMore(false)
}
}, [query, t])
useEffect(() => {
void load()
}, [load])
useEffect(() => {
if (state.status !== 'ready' || !state.data.settings.enabled) return
const timer = window.setInterval(() => {
void load({
limit: Math.max(PAGE_SIZE, state.data.traces.length),
silent: true,
})
}, POLL_MS)
return () => window.clearInterval(timer)
}, [load, state])
const summary = useMemo(() => {
if (state.status !== 'ready') return { apiCalls: 0, failedCalls: 0, models: 0 }
const modelNames = new Set<string>()
let apiCalls = 0
let failedCalls = 0
for (const item of state.data.traces) {
apiCalls += item.summary.apiCalls
failedCalls += item.summary.failedCalls
for (const model of item.summary.models) modelNames.add(model.model)
}
return { apiCalls, failedCalls, models: modelNames.size }
}, [state])
return (
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]">
<header className="shrink-0 border-b border-[var(--color-border)] px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase text-[var(--color-text-tertiary)]">
<Workflow className="h-4 w-4" strokeWidth={1.8} aria-hidden="true" />
<span>{t('trace.list.eyebrow')}</span>
{state.status === 'ready' && (
<span className={`rounded-md border px-1.5 py-0.5 ${
state.data.settings.enabled
? 'border-[var(--color-success)]/25 bg-[var(--color-success)]/10 text-[var(--color-success)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]'
}`}>
{state.data.settings.enabled ? t('trace.list.collecting') : t('trace.list.paused')}
</span>
)}
</div>
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-[var(--color-text-primary)]">{t('trace.list.title')}</h1>
{state.status === 'ready' && (
<p className="mt-1 truncate text-xs text-[var(--color-text-tertiary)]">
{state.data.storageDir}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => openTraceSettings(t)}>
{t('trace.list.settings')}
</Button>
<Button size="sm" variant="secondary" onClick={() => void load()}>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
{t('trace.refresh')}
</Button>
</div>
</div>
<div className="mt-4 grid grid-cols-4 gap-3">
<Metric label={t('trace.list.sessions')} value={state.status === 'ready' ? String(state.data.total) : '-'} />
<Metric label={t('trace.apiCalls')} value={String(summary.apiCalls)} />
<Metric label={t('trace.failedCalls')} value={String(summary.failedCalls)} tone={summary.failedCalls > 0 ? 'danger' : 'default'} />
<Metric label={t('trace.models')} value={String(summary.models)} />
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-[var(--color-border)] px-5 py-3">
<div className="flex h-10 max-w-xl items-center rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 focus-within:border-[var(--color-border-focus)]">
<Search className="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={1.8} aria-hidden="true" />
<input
value={queryInput}
onChange={(event) => setQueryInput(event.currentTarget.value)}
placeholder={t('trace.list.searchPlaceholder')}
className="min-w-0 flex-1 bg-transparent px-2 text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
</div>
</div>
{state.status === 'loading' && (
<div className="flex flex-1 items-center justify-center text-sm text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
)}
{state.status === 'error' && (
<div className="m-5 rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error)]/5 p-4 text-sm text-[var(--color-error)]">
{state.message}
</div>
)}
{state.status === 'ready' && (
<TraceRows
traces={state.data.traces}
total={state.data.total}
loadingMore={isLoadingMore}
onLoadMore={() => void load({
append: true,
offset: state.data.traces.length,
silent: true,
})}
onOpenWindow={(sessionId) => {
if (host.trace) void host.trace.openWindow(sessionId)
}}
/>
)}
</div>
</div>
)
}
function TraceRows({
loadingMore,
onLoadMore,
traces,
total,
onOpenWindow,
}: {
loadingMore: boolean
onLoadMore: () => void
traces: TraceSessionListItem[]
total: number
onOpenWindow: (sessionId: string) => void
}) {
const t = useTranslation()
if (traces.length === 0) {
return (
<div className="flex flex-1 items-center justify-center px-6 text-center">
<div className="max-w-sm">
<Workflow className="mx-auto h-8 w-8 text-[var(--color-text-tertiary)]" strokeWidth={1.5} aria-hidden="true" />
<h2 className="mt-3 text-sm font-semibold text-[var(--color-text-primary)]">{t('trace.list.emptyTitle')}</h2>
<p className="mt-2 text-sm leading-6 text-[var(--color-text-secondary)]">{t('trace.list.emptyBody')}</p>
</div>
</div>
)
}
return (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="grid grid-cols-[minmax(260px,1.5fr)_120px_90px_120px_minmax(160px,1fr)_96px] border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-5 py-2 text-[10px] font-semibold uppercase text-[var(--color-text-tertiary)]">
<div>{t('trace.list.session')}</div>
<div>{t('trace.apiCalls')}</div>
<div>{t('trace.failedCalls')}</div>
<div>{t('trace.duration')}</div>
<div>{t('trace.models')}</div>
<div className="text-right">{t('trace.list.actions')}</div>
</div>
{traces.map((trace) => (
<TraceRow key={trace.sessionId} trace={trace} onOpenWindow={onOpenWindow} />
))}
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-5 py-3 text-xs text-[var(--color-text-tertiary)]">
<span>{t('trace.list.loadedCount', { shown: traces.length, total })}</span>
{traces.length < total && (
<Button size="sm" variant="secondary" onClick={onLoadMore} disabled={loadingMore}>
{loadingMore ? t('common.loading') : t('trace.list.loadMore')}
</Button>
)}
</div>
</div>
)
}
function TraceRow({
trace,
onOpenWindow,
}: {
trace: TraceSessionListItem
onOpenWindow: (sessionId: string) => void
}) {
const t = useTranslation()
const title = trace.session?.title || t('session.untitled')
const modelLabel = trace.summary.models.map((model) => `${model.model} x${model.calls}`).join(', ') || t('trace.noModel')
const updatedAt = trace.summary.updatedAt ?? trace.fileUpdatedAt
const hasError = trace.summary.failedCalls > 0
return (
<div className="grid grid-cols-[minmax(260px,1.5fr)_120px_90px_120px_minmax(160px,1fr)_96px] items-center gap-0 border-b border-[var(--color-border)] px-5 py-3 text-sm hover:bg-[var(--color-surface-hover)]">
<div className="min-w-0">
<button
type="button"
onClick={() => openTrace(trace.sessionId, title, t)}
className="block min-w-0 text-left"
>
<div className="flex min-w-0 items-center gap-2">
{hasError
? <AlertTriangle className="h-4 w-4 shrink-0 text-[var(--color-error)]" strokeWidth={1.8} aria-hidden="true" />
: <CheckCircle2 className="h-4 w-4 shrink-0 text-[var(--color-success)]" strokeWidth={1.8} aria-hidden="true" />}
<span className="truncate font-medium text-[var(--color-text-primary)]">{title}</span>
</div>
<div className="mt-1 flex min-w-0 gap-2 text-xs text-[var(--color-text-tertiary)]">
<span className="truncate font-mono">{trace.sessionId}</span>
<span className="shrink-0">{formatUpdatedAt(updatedAt)}</span>
</div>
{trace.session?.projectPath && (
<div className="mt-1 truncate text-[11px] text-[var(--color-text-tertiary)]">{trace.session.projectPath}</div>
)}
</button>
</div>
<div className="font-mono text-[var(--color-text-primary)]">{trace.summary.apiCalls}</div>
<div className={hasError ? 'font-mono text-[var(--color-error)]' : 'font-mono text-[var(--color-text-primary)]'}>{trace.summary.failedCalls}</div>
<div className="font-mono text-[var(--color-text-secondary)]">{formatDuration(trace.summary.totalDurationMs)}</div>
<div className="min-w-0 truncate text-xs text-[var(--color-text-secondary)]" title={modelLabel}>{modelLabel}</div>
<div className="flex justify-end gap-1">
<button
type="button"
onClick={() => openTrace(trace.sessionId, title, t)}
className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
aria-label={t('trace.open')}
title={t('trace.open')}
>
<Workflow className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => onOpenWindow(trace.sessionId)}
className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
aria-label={t('trace.openWindow')}
title={t('trace.openWindow')}
>
<ExternalLink className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="col-span-6 mt-2 hidden text-[11px] text-[var(--color-text-tertiary)] md:block">
{t('trace.list.fileSize')}: {formatBytes(trace.fileSize)}
</div>
</div>
)
}
function Metric({ label, value, tone = 'default' }: { label: string; value: string; tone?: 'default' | 'danger' }) {
return (
<div>
<div className="text-[10px] font-semibold uppercase text-[var(--color-text-tertiary)]">{label}</div>
<div className={`mt-1 truncate font-mono text-lg ${tone === 'danger' ? 'text-[var(--color-error)]' : 'text-[var(--color-text-primary)]'}`}>{value}</div>
</div>
)
}
function openTrace(sessionId: string, title: string, t: ReturnType<typeof useTranslation>) {
useTabStore.getState().openTraceTab(sessionId, `${t('trace.title')}: ${title}`)
}
function openTraceSettings(t: ReturnType<typeof useTranslation>) {
useUIStore.getState().setPendingSettingsTab('general')
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')
}
function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '-'
if (ms < 1000) return `${Math.round(ms)}ms`
return `${(ms / 1000).toFixed(1)}s`
}
function formatUpdatedAt(value: string | null): string {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString()
}

View File

@ -0,0 +1,174 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TraceSession } from './TraceSession'
import { sessionsApi } from '../api/sessions'
import { useSessionStore } from '../stores/sessionStore'
import { useSettingsStore } from '../stores/settingsStore'
import type { TraceSession as TraceSessionData } from '../types/trace'
vi.mock('../api/sessions', () => ({
sessionsApi: {
getTrace: vi.fn(),
getMessages: vi.fn(),
},
}))
const baseTrace: TraceSessionData = {
sessionId: 'session-live',
session: {
id: 'session-live',
title: 'Trace API title',
projectPath: '/tmp',
workDir: '/tmp',
},
summary: {
apiCalls: 1,
failedCalls: 0,
totalDurationMs: 1200,
totalInputTokens: 0,
totalOutputTokens: 0,
models: [{ model: 'gpt-5.5', calls: 1 }],
updatedAt: '2026-06-09T10:10:00.000Z',
},
calls: [{
id: 'call-1',
sessionId: 'session-live',
source: 'anthropic' as const,
provider: { id: 'provider-sub2api', name: 'Sub2API-ChatGPT', format: 'anthropic' },
model: 'gpt-5.5',
startedAt: '2026-06-09T10:09:59.000Z',
completedAt: '2026-06-09T10:10:00.000Z',
durationMs: 1200,
request: {
method: 'POST',
url: 'https://sub2api.example/v1/messages',
headers: { authorization: '[redacted]' },
body: {
contentType: 'json' as const,
bytes: 26,
sha256: 'a'.repeat(64),
preview: '{"model":"gpt-5.5"}',
truncated: false,
},
},
response: {
status: 200,
headers: {},
body: {
contentType: 'json' as const,
bytes: 11,
sha256: 'b'.repeat(64),
preview: '{"ok":true}',
truncated: false,
},
},
}],
}
describe('TraceSession', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
vi.mocked(sessionsApi.getMessages).mockResolvedValue({ messages: [] })
useSessionStore.setState({
sessions: [{
id: 'session-live',
title: 'Live probe',
createdAt: '2026-06-09T10:00:00.000Z',
modifiedAt: '2026-06-09T10:10:00.000Z',
messageCount: 0,
projectPath: '/tmp',
workDir: '/tmp',
workDirExists: true,
}],
activeSessionId: 'session-live',
isLoading: false,
error: null,
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useSettingsStore.setState({ locale: 'en' })
})
it('refreshes the trace snapshot while the page is open', async () => {
vi.mocked(sessionsApi.getTrace)
.mockResolvedValueOnce(baseTrace)
.mockResolvedValueOnce({
...baseTrace,
summary: {
...baseTrace.summary,
apiCalls: 2,
models: [{ model: 'gpt-5.5', calls: 2 }],
},
calls: [
...baseTrace.calls,
{ ...baseTrace.calls[0]!, id: 'call-2', durationMs: 900 },
],
})
render(<TraceSession sessionId="session-live" pollIntervalMs={20} />)
await screen.findByText('gpt-5.5 x1')
expect(sessionsApi.getTrace).toHaveBeenCalledWith('session-live')
await waitFor(() => expect(screen.getByText('gpt-5.5 x2')).toBeInTheDocument())
expect(vi.mocked(sessionsApi.getTrace).mock.calls.length).toBeGreaterThanOrEqual(2)
})
it('uses trace session metadata when the sidebar store has not loaded the session', async () => {
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
vi.mocked(sessionsApi.getTrace).mockResolvedValue(baseTrace)
render(<TraceSession sessionId="session-live" standalone pollIntervalMs={60_000} />)
expect(await screen.findByRole('heading', { level: 1, name: 'Trace API title' })).toBeInTheDocument()
})
it('renders lifecycle events as trace spans', async () => {
vi.mocked(sessionsApi.getTrace).mockResolvedValue({
...baseTrace,
events: [{
id: 'event-failed',
sessionId: 'session-live',
callId: 'call-1',
source: 'anthropic',
timestamp: '2026-06-09T10:10:00.100Z',
phase: 'api_call_failed',
severity: 'error',
message: 'network down',
}],
})
render(<TraceSession sessionId="session-live" pollIntervalMs={60_000} />)
expect((await screen.findAllByText('API call failed')).length).toBeGreaterThan(0)
expect(screen.getAllByText('network down').length).toBeGreaterThan(0)
expect(screen.getByText('Trace event failed')).toBeInTheDocument()
})
it('renders trace navigation and inspector labels in the active locale', async () => {
useSettingsStore.setState({ locale: 'zh' })
vi.mocked(sessionsApi.getTrace).mockResolvedValue(baseTrace)
render(<TraceSession sessionId="session-live" pollIntervalMs={60_000} />)
expect(await screen.findByText('运行树')).toBeInTheDocument()
expect(screen.getByPlaceholderText('搜索 Span')).toBeInTheDocument()
expect(screen.getByText('对话线程')).toBeInTheDocument()
expect(screen.getByText('诊断')).toBeInTheDocument()
expect(screen.getByRole('tab', { name: '输入' })).toBeInTheDocument()
expect(screen.queryByText('Run tree')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('Search spans')).not.toBeInTheDocument()
expect(screen.queryByText('Thread')).not.toBeInTheDocument()
expect(screen.queryByText('Session activity')).not.toBeInTheDocument()
fireEvent.click(screen.getAllByRole('button', { name: /gpt-5\.5/ })[0]!)
fireEvent.click(screen.getByRole('tab', { name: '输入' }))
expect(screen.getByText('请求正文')).toBeInTheDocument()
expect(screen.getByText('请求头')).toBeInTheDocument()
})
})

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@ import { ApiError } from '../api/client'
import { settingsApi } from '../api/settings'
import { modelsApi } from '../api/models'
import { h5AccessApi } from '../api/h5Access'
import { tracesApi } from '../api/traces'
import {
isThemeMode,
type AppMode,
@ -23,6 +24,7 @@ import {
type UpdateProxySettings,
type WebSearchSettings,
} from '../types/settings'
import type { TraceCaptureSettings } from '../types/trace'
import { getDesktopHost } from '../lib/desktopHost'
import type { Locale } from '../i18n'
import {
@ -75,6 +77,7 @@ type SettingsStore = {
webSearch: WebSearchSettings
updateProxy: UpdateProxySettings
network: NetworkSettings
traceCapture: TraceCaptureSettings
h5Access: H5AccessSettings
h5AccessDiagnostics: H5AccessDiagnostics | null
h5AccessError: string | null
@ -103,6 +106,7 @@ type SettingsStore = {
setWebSearch: (settings: WebSearchSettings) => Promise<void>
setUpdateProxy: (settings: UpdateProxySettings) => Promise<void>
setNetwork: (settings: NetworkSettings) => Promise<void>
setTraceCaptureEnabled: (enabled: boolean) => Promise<void>
enableH5Access: () => Promise<string>
disableH5Access: () => Promise<void>
regenerateH5AccessToken: () => Promise<string>
@ -155,6 +159,11 @@ const DEFAULT_OUTPUT_STYLE_OPTIONS: OutputStyleOption[] = [
},
]
const DEFAULT_TRACE_CAPTURE_SETTINGS: TraceCaptureSettings = {
enabled: true,
storageDir: '',
}
export const useSettingsStore = create<SettingsStore>((set, get) => ({
permissionMode: 'default',
currentModel: null,
@ -177,6 +186,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
updateProxy: DEFAULT_UPDATE_PROXY_SETTINGS,
network: DEFAULT_NETWORK_SETTINGS,
traceCapture: DEFAULT_TRACE_CAPTURE_SETTINGS,
h5Access: DEFAULT_H5_ACCESS_SETTINGS,
h5AccessDiagnostics: null,
h5AccessError: null,
@ -203,13 +213,14 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
set({ isLoading: true, error: null })
try {
const previousH5Access = get().h5Access
const [{ mode }, modelsRes, { model }, { level }, userSettings, h5AccessResult] = await Promise.all([
const [{ mode }, modelsRes, { model }, { level }, userSettings, h5AccessResult, traceCapture] = await Promise.all([
settingsApi.getPermissionMode(),
modelsApi.list(),
modelsApi.getCurrent(),
modelsApi.getEffort(),
settingsApi.getUser(),
loadH5AccessSettings(previousH5Access),
loadTraceCaptureSettings(),
])
const theme = isThemeMode(userSettings.theme) ? userSettings.theme : 'white'
useUIStore.getState().setTheme(theme)
@ -229,6 +240,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
webSearch: normalizeWebSearchSettings(userSettings.webSearch),
updateProxy: normalizeUpdateProxySettings(userSettings.updateProxy),
network: normalizeNetworkSettings(userSettings.network),
traceCapture,
h5Access: h5AccessResult.settings,
h5AccessDiagnostics: h5AccessResult.diagnostics,
h5AccessError: h5AccessResult.error,
@ -447,6 +459,18 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
}
},
setTraceCaptureEnabled: async (enabled) => {
const prev = get().traceCapture
set({ traceCapture: { ...prev, enabled } })
try {
const next = await tracesApi.updateSettings({ enabled })
set({ traceCapture: normalizeTraceCaptureSettings(next) })
} catch (error) {
set({ traceCapture: prev })
throw error
}
},
enableH5Access: async () => {
set({ h5AccessError: null })
try {
@ -624,6 +648,15 @@ function normalizeNetworkSettings(
}
}
function normalizeTraceCaptureSettings(
settings: TraceCaptureSettings | undefined,
): TraceCaptureSettings {
return {
enabled: settings?.enabled !== false,
storageDir: typeof settings?.storageDir === 'string' ? settings.storageDir : '',
}
}
function normalizeDesktopTerminalSettings(
settings: Partial<DesktopTerminalSettings> | undefined,
): DesktopTerminalSettings {
@ -648,6 +681,14 @@ function normalizeH5AccessSettings(settings: H5AccessSettings | undefined): H5Ac
}
}
async function loadTraceCaptureSettings(): Promise<TraceCaptureSettings> {
try {
return normalizeTraceCaptureSettings(await tracesApi.getSettings())
} catch {
return DEFAULT_TRACE_CAPTURE_SETTINGS
}
}
async function refreshH5DiagnosticsSilent(
set: (partial: Partial<SettingsStore>) => void,
): Promise<void> {

View File

@ -1,10 +1,18 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { useTabStore } from './tabStore'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { sessionsApi } from '../api/sessions'
import { SETTINGS_TAB_ID, useTabStore } from './tabStore'
vi.mock('../api/sessions', () => ({
sessionsApi: {
list: vi.fn(async () => ({ sessions: [] })),
},
}))
describe('tabStore', () => {
beforeEach(() => {
useTabStore.setState({ tabs: [], activeTabId: null })
localStorage.clear()
vi.mocked(sessionsApi.list).mockResolvedValue({ sessions: [] } as never)
})
it('refreshes an existing tab title when opening the same session again', () => {
@ -35,4 +43,30 @@ describe('tabStore', () => {
])
expect(useTabStore.getState().activeTabId).toBe(tabId)
})
it('does not let async tab restore overwrite tabs opened while restore is in flight', async () => {
let resolveSessions: (value: unknown) => void = () => {}
vi.mocked(sessionsApi.list).mockReturnValueOnce(new Promise((resolve) => {
resolveSessions = resolve
}) as never)
localStorage.setItem('cc-haha-open-tabs', JSON.stringify({
openTabs: [{ sessionId: 'session-1', title: 'Old Session', type: 'session' }],
activeTabId: 'session-1',
}))
const restore = useTabStore.getState().restoreTabs()
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
resolveSessions({ sessions: [{ id: 'session-1', title: 'Old Session' }] })
await restore
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
expect(useTabStore.getState().tabs).toEqual([
{
sessionId: SETTINGS_TAB_ID,
title: 'Settings',
type: 'settings',
status: 'idle',
},
])
})
})

View File

@ -7,9 +7,11 @@ const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
export const SETTINGS_TAB_ID = '__settings__'
export const SCHEDULED_TAB_ID = '__scheduled__'
export const TRACE_LIST_TAB_ID = '__traces__'
export const TERMINAL_TAB_PREFIX = '__terminal__'
export const TRACE_TAB_PREFIX = '__trace__'
export type TabType = 'session' | 'settings' | 'scheduled' | 'terminal'
export type TabType = 'session' | 'settings' | 'scheduled' | 'terminal' | 'trace' | 'traces'
export type Tab = {
sessionId: string
@ -18,10 +20,11 @@ export type Tab = {
status: 'idle' | 'running' | 'error'
terminalCwd?: string
terminalRuntimeId?: string
traceSessionId?: string
}
type TabPersistence = {
openTabs: Array<{ sessionId: string; title: string; type?: TabType }>
openTabs: Array<{ sessionId: string; title: string; type?: TabType; traceSessionId?: string }>
activeTabId: string | null
}
@ -30,6 +33,8 @@ type TabStore = {
activeTabId: string | null
openTab: (sessionId: string, title: string, type?: TabType) => void
openTracesTab: (title?: string) => string
openTraceTab: (sessionId: string, title?: string) => string
openTerminalTab: (cwd?: string, terminalRuntimeId?: string) => string
closeTab: (sessionId: string) => void
setActiveTab: (sessionId: string) => void
@ -71,6 +76,51 @@ export const useTabStore = create<TabStore>((set, get) => ({
get().saveTabs()
},
openTracesTab: (title = 'Traces') => {
const { tabs } = get()
const existing = tabs.find((tab) => tab.sessionId === TRACE_LIST_TAB_ID)
if (existing) {
set({
tabs: tabs.map((tab) => (
tab.sessionId === TRACE_LIST_TAB_ID
? { ...tab, title, type: 'traces' }
: tab
)),
activeTabId: TRACE_LIST_TAB_ID,
})
} else {
set({
tabs: [...tabs, { sessionId: TRACE_LIST_TAB_ID, title, type: 'traces', status: 'idle' }],
activeTabId: TRACE_LIST_TAB_ID,
})
}
get().saveTabs()
return TRACE_LIST_TAB_ID
},
openTraceTab: (sessionId, title = 'Trace') => {
const traceTabId = `${TRACE_TAB_PREFIX}${sessionId}`
const { tabs } = get()
const existing = tabs.find((tab) => tab.sessionId === traceTabId)
if (existing) {
set({
tabs: tabs.map((tab) => (
tab.sessionId === traceTabId
? { ...tab, title, type: 'trace', traceSessionId: sessionId }
: tab
)),
activeTabId: traceTabId,
})
} else {
set({
tabs: [...tabs, { sessionId: traceTabId, title, type: 'trace', status: 'idle', traceSessionId: sessionId }],
activeTabId: traceTabId,
})
}
get().saveTabs()
return traceTabId
},
openTerminalTab: (cwd, terminalRuntimeId) => {
const { tabs } = get()
const nextIndex = Math.max(
@ -162,7 +212,12 @@ export const useTabStore = create<TabStore>((set, get) => ({
const { tabs, activeTabId } = get()
const persistableTabs = tabs.filter((tab) => tab.type !== 'terminal')
const data: TabPersistence = {
openTabs: persistableTabs.map((t) => ({ sessionId: t.sessionId, title: t.title, type: t.type })),
openTabs: persistableTabs.map((t) => ({
sessionId: t.sessionId,
title: t.title,
type: t.type,
...(t.traceSessionId ? { traceSessionId: t.traceSessionId } : {}),
})),
activeTabId: activeTabId && persistableTabs.some((tab) => tab.sessionId === activeTabId)
? activeTabId
: (persistableTabs[0]?.sessionId ?? null),
@ -174,6 +229,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
restoreTabs: async () => {
try {
const restoreStartedWith = get()
const raw = localStorage.getItem(TAB_STORAGE_KEY)
if (!raw) return
@ -185,20 +241,38 @@ export const useTabStore = create<TabStore>((set, get) => ({
}
const { sessions } = await sessionsApi.list({ limit: 200 })
const current = get()
if (
current.tabs !== restoreStartedWith.tabs ||
current.activeTabId !== restoreStartedWith.activeTabId
) {
return
}
const existingIds = new Set(sessions.map((s) => s.id))
const validTabs: Tab[] = data.openTabs
.filter((t) => {
// Special tabs are always valid
if (t.type === 'settings' || t.type === 'scheduled') return true
if (t.type === 'settings' || t.type === 'scheduled' || t.type === 'traces') return true
if (t.type === 'trace') return !!t.traceSessionId && existingIds.has(t.traceSessionId)
if (t.type === 'terminal') return false
// Session tabs must exist on server
return existingIds.has(t.sessionId)
})
.map((t) => {
if (t.type === 'settings' || t.type === 'scheduled') {
if (t.type === 'settings' || t.type === 'scheduled' || t.type === 'traces') {
return { sessionId: t.sessionId, title: t.title, type: t.type, status: 'idle' as const }
}
if (t.type === 'trace' && t.traceSessionId) {
const sourceTitle = sessions.find((s) => s.id === t.traceSessionId)?.title || t.title
return {
sessionId: `${TRACE_TAB_PREFIX}${t.traceSessionId}`,
title: sourceTitle === t.title ? t.title : `Trace: ${sourceTitle}`,
type: 'trace' as const,
status: 'idle' as const,
traceSessionId: t.traceSessionId,
}
}
return {
sessionId: t.sessionId,
title: sessions.find((s) => s.id === t.sessionId)?.title || t.title,

111
desktop/src/types/trace.ts Normal file
View File

@ -0,0 +1,111 @@
export type TraceBodySnapshot = {
contentType: 'json' | 'text' | 'empty'
bytes: number
sha256: string
preview: string
truncated: boolean
}
export type TraceProviderInfo = {
id: string | null
name: string
format: string
}
export type TraceCallStatus = 'pending' | 'ok' | 'error'
export type TraceEventSeverity = 'info' | 'warning' | 'error'
export type TraceCallRecord = {
id: string
sessionId: string
source: 'anthropic' | 'proxy'
querySource?: string
provider?: TraceProviderInfo
model?: string
status?: TraceCallStatus
startedAt: string
completedAt?: string
durationMs?: number
metadata?: Record<string, unknown>
request: {
method: string
url: string
headers: Record<string, string>
body: TraceBodySnapshot
}
response?: {
status: number
headers: Record<string, string>
body: TraceBodySnapshot
}
error?: {
name: string
message: string
code?: string
stack?: string
cause?: string
}
}
export type TraceEventRecord = {
id: string
sessionId: string
timestamp: string
phase: string
severity: TraceEventSeverity
callId?: string
source?: TraceCallRecord['source']
provider?: TraceProviderInfo
model?: string
title?: string
message?: string
metadata?: Record<string, unknown>
}
export type TraceSessionSummary = {
apiCalls: number
failedCalls: number
totalDurationMs: number
totalInputTokens: number
totalOutputTokens: number
models: Array<{ model: string; calls: number }>
updatedAt: string | null
}
export type TraceSession = {
sessionId: string
session?: {
id: string
title: string
projectPath: string
workDir: string | null
} | null
summary: TraceSessionSummary
calls: TraceCallRecord[]
events?: TraceEventRecord[]
}
export type TraceCaptureSettings = {
enabled: boolean
storageDir: string
}
export type TraceSessionListItem = {
sessionId: string
session: {
id: string
title: string
projectPath: string
workDir: string | null
} | null
summary: TraceSessionSummary
fileSize: number
fileUpdatedAt: string
}
export type TraceSessionList = {
traces: TraceSessionListItem[]
total: number
storageDir: string
settings: TraceCaptureSettings
}

View File

@ -94,6 +94,7 @@ import { executePostSamplingHooks } from './utils/hooks/postSamplingHooks.js'
import { executeStopFailureHooks } from './utils/hooks.js'
import type { QuerySource } from './constants/querySource.js'
import { createDumpPromptsFetch } from './services/api/dumpPrompts.js'
import { shouldCaptureApiTrace } from './services/api/traceCapture.js'
import { StreamingToolExecutor } from './services/tools/StreamingToolExecutor.js'
import { queryCheckpoint } from './utils/queryProfiler.js'
import { runTools } from './services/tools/toolOrchestration.js'
@ -587,8 +588,11 @@ async function* queryLoop(
// instead of all request bodies from the session (~500MB for long sessions).
// Note: agentId is effectively constant during a query() call - it only changes
// between queries (e.g., /clear command or session resume).
const dumpPromptsFetch = config.gates.isAnt
? createDumpPromptsFetch(toolUseContext.agentId ?? config.sessionId)
const dumpPromptsFetch = config.gates.isAnt || shouldCaptureApiTrace()
? createDumpPromptsFetch(toolUseContext.agentId ?? config.sessionId, {
traceSessionId: config.sessionId,
querySource,
})
: undefined
// Block if we've hit the hard blocking limit (only applies when auto-compact is OFF)

View File

@ -7,6 +7,7 @@ import {
DESKTOP_CLI_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
} from '../services/conversationService.js'
import { ProviderService } from '../services/providerService.js'
import { updateTraceCaptureSettings } from '../services/traceCaptureService.js'
import { resetTerminalShellEnvironmentCacheForTests } from '../../utils/terminalShellEnvironment.js'
describe('ConversationService', () => {
@ -22,6 +23,10 @@ describe('ConversationService', () => {
let originalDiagnosticsFile: string | undefined
let originalAttributionHeader: string | undefined
let originalResumeInterruptedTurn: string | undefined
let originalTraceApiCalls: string | undefined
let originalTraceProviderId: string | undefined
let originalTraceProviderName: string | undefined
let originalTraceProviderFormat: string | undefined
let originalHome: string | undefined
let originalPath: string | undefined
let originalShell: string | undefined
@ -41,6 +46,10 @@ describe('ConversationService', () => {
originalDiagnosticsFile = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
originalAttributionHeader = process.env.CLAUDE_CODE_ATTRIBUTION_HEADER
originalResumeInterruptedTurn = process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN
originalTraceApiCalls = process.env.CC_HAHA_TRACE_API_CALLS
originalTraceProviderId = process.env.CC_HAHA_TRACE_PROVIDER_ID
originalTraceProviderName = process.env.CC_HAHA_TRACE_PROVIDER_NAME
originalTraceProviderFormat = process.env.CC_HAHA_TRACE_PROVIDER_FORMAT
originalHome = process.env.HOME
originalPath = process.env.PATH
originalShell = process.env.SHELL
@ -60,6 +69,10 @@ describe('ConversationService', () => {
delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER
delete process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN
delete process.env.CC_HAHA_TRACE_API_CALLS
delete process.env.CC_HAHA_TRACE_PROVIDER_ID
delete process.env.CC_HAHA_TRACE_PROVIDER_NAME
delete process.env.CC_HAHA_TRACE_PROVIDER_FORMAT
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1'
resetTerminalShellEnvironmentCacheForTests()
})
@ -98,6 +111,18 @@ describe('ConversationService', () => {
if (originalResumeInterruptedTurn === undefined) delete process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN
else process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN = originalResumeInterruptedTurn
if (originalTraceApiCalls === undefined) delete process.env.CC_HAHA_TRACE_API_CALLS
else process.env.CC_HAHA_TRACE_API_CALLS = originalTraceApiCalls
if (originalTraceProviderId === undefined) delete process.env.CC_HAHA_TRACE_PROVIDER_ID
else process.env.CC_HAHA_TRACE_PROVIDER_ID = originalTraceProviderId
if (originalTraceProviderName === undefined) delete process.env.CC_HAHA_TRACE_PROVIDER_NAME
else process.env.CC_HAHA_TRACE_PROVIDER_NAME = originalTraceProviderName
if (originalTraceProviderFormat === undefined) delete process.env.CC_HAHA_TRACE_PROVIDER_FORMAT
else process.env.CC_HAHA_TRACE_PROVIDER_FORMAT = originalTraceProviderFormat
if (originalHome === undefined) delete process.env.HOME
else process.env.HOME = originalHome
@ -366,6 +391,68 @@ describe('ConversationService', () => {
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1')
expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0')
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
expect(env.CC_HAHA_TRACE_PROVIDER_ID).toBeUndefined()
expect(env.CC_HAHA_TRACE_PROVIDER_NAME).toBeUndefined()
expect(env.CC_HAHA_TRACE_PROVIDER_FORMAT).toBeUndefined()
})
test('buildChildEnv injects trace provider metadata for desktop sdk session-scoped providers', async () => {
const providerService = new ProviderService()
const provider = await providerService.addProvider({
presetId: 'custom',
name: 'Traceable Provider',
apiKey: 'provider-key',
baseUrl: 'https://traceable.example',
apiFormat: 'anthropic',
models: {
main: 'gpt-5.5',
haiku: '',
sonnet: '',
opus: '',
},
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv(
'/tmp',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
{ providerId: provider.id },
)) as Record<string, string>
expect(env.CC_HAHA_TRACE_API_CALLS).toBe('1')
expect(env.CC_HAHA_TRACE_PROVIDER_ID).toBe(provider.id)
expect(env.CC_HAHA_TRACE_PROVIDER_NAME).toBe('Traceable Provider')
expect(env.CC_HAHA_TRACE_PROVIDER_FORMAT).toBe('anthropic')
})
test('buildChildEnv does not inject trace env when managed trace capture is disabled', async () => {
await updateTraceCaptureSettings({ enabled: false })
const providerService = new ProviderService()
const provider = await providerService.addProvider({
presetId: 'custom',
name: 'Trace disabled provider',
apiKey: 'provider-key',
baseUrl: 'https://traceable.example',
apiFormat: 'anthropic',
models: {
main: 'gpt-5.5',
haiku: '',
sonnet: '',
opus: '',
},
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv(
'/tmp',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
{ providerId: provider.id },
)) as Record<string, string>
expect(env.CC_HAHA_TRACE_API_CALLS).toBeUndefined()
expect(env.CC_HAHA_TRACE_PROVIDER_ID).toBeUndefined()
expect(env.CC_HAHA_TRACE_PROVIDER_NAME).toBeUndefined()
expect(env.CC_HAHA_TRACE_PROVIDER_FORMAT).toBeUndefined()
})
test('buildChildEnv uses the session-selected model for session-scoped providers', async () => {
@ -561,6 +648,7 @@ describe('ConversationService', () => {
'com.claude-code-haha.desktop',
)
expect(env.CC_HAHA_DESKTOP_SERVER_URL).toBe('http://127.0.0.1:3456')
expect(env.CC_HAHA_TRACE_API_CALLS).toBe('1')
expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBe('1')
})

View File

@ -3209,6 +3209,7 @@ describe('WebSocket Chat Integration', () => {
return body.status?.permissionMode === 'default'
}, `persisted bypass-to-default permission switch for ${sessionId}`)
expect(startCalls).toHaveLength(1)
expect(conversationService.getSessionPermissionMode(sessionId)).toBe('default')
expect(messages.slice(switchStartIndex).some((msg) => msg.type === 'error')).toBe(false)
} finally {
ws.close()

View File

@ -9,6 +9,7 @@ import * as os from 'os'
import { ProviderService } from '../services/providerService.js'
import { handleProvidersApi } from '../api/providers.js'
import { handleProxyRequest } from '../proxy/handler.js'
import { clearTraceCaptureStateForTests, traceCaptureService } from '../services/traceCaptureService.js'
import type { CreateProviderInput } from '../types/provider.js'
// ─── Test helpers ─────────────────────────────────────────────────────────────
@ -20,9 +21,11 @@ async function setup() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'provider-test-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tmpDir
clearTraceCaptureStateForTests()
}
async function teardown() {
clearTraceCaptureStateForTests()
if (originalConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
} else {
@ -944,6 +947,61 @@ describe('ProviderService', () => {
})
describe('handleProxyRequest', () => {
test('records a session trace for proxied OpenAI Chat calls', async () => {
const originalFetch = globalThis.fetch
globalThis.fetch = mock(async () => {
return new Response(JSON.stringify({
id: 'chatcmpl-trace',
object: 'chat.completion',
created: 0,
model: 'gpt-4',
choices: [{ index: 0, message: { role: 'assistant', content: 'trace ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 11, completion_tokens: 3, total_tokens: 14 },
}), {
status: 200,
headers: { 'Content-Type': 'application/json', 'x-request-id': 'req-trace' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({ apiFormat: 'openai_chat', name: 'Trace Provider' }))
await svc.activateProvider(provider.id)
const req = new Request('http://localhost:3456/proxy/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Claude-Code-Session-Id': 'session-proxy-trace',
},
body: JSON.stringify({
model: 'gpt-4',
max_tokens: 64,
messages: [{ role: 'user', content: 'capture this call' }],
}),
})
const res = await handleProxyRequest(req, new URL(req.url))
const trace = await traceCaptureService.getSessionTrace('session-proxy-trace')
expect(res.status).toBe(200)
expect(trace.summary.apiCalls).toBe(1)
expect(trace.calls[0]).toMatchObject({
source: 'proxy',
provider: {
id: provider.id,
name: 'Trace Provider',
format: 'openai_chat',
},
model: 'gpt-4',
})
expect(trace.calls[0].request.body.preview).toContain('capture this call')
expect(trace.calls[0].response.body.preview).toContain('chatcmpl-trace')
} finally {
globalThis.fetch = originalFetch
}
})
test('injects Claude Code billing attribution with compat version and signed CCH', async () => {
const originalFetch = globalThis.fetch
const originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT

View File

@ -0,0 +1,542 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import { handleApiRequest } from '../router.js'
import {
clearTraceCaptureStateForTests,
createTraceCallId,
createTraceBodySnapshot,
traceCaptureService,
updateTraceCaptureSettings,
} from '../services/traceCaptureService.js'
import { sessionService } from '../services/sessionService.js'
import { createDumpPromptsFetch } from '../../services/api/dumpPrompts.js'
let tmpDir: string
let originalConfigDir: string | undefined
async function waitForTrace(
sessionId: string,
predicate: (trace: Awaited<ReturnType<typeof traceCaptureService.getSessionTrace>>) => boolean,
) {
for (let attempt = 0; attempt < 30; attempt++) {
const trace = await traceCaptureService.getSessionTrace(sessionId)
if (predicate(trace)) return trace
await new Promise((resolve) => setTimeout(resolve, 10))
}
return traceCaptureService.getSessionTrace(sessionId)
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'trace-capture-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tmpDir
clearTraceCaptureStateForTests()
})
afterEach(async () => {
clearTraceCaptureStateForTests()
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
}
await fs.rm(tmpDir, { recursive: true, force: true })
})
describe('trace capture service', () => {
test('stores session scoped API calls with redacted headers and capped bodies', async () => {
const body = {
model: 'deepseek-v4-pro',
api_key: 'sk-body-secret',
messages: [
{ role: 'user', content: 'explain the failed provider response' },
],
padding: 'x'.repeat(3000),
}
await traceCaptureService.recordCall({
sessionId: 'session-trace-1',
source: 'proxy',
querySource: 'repl_main_thread',
provider: {
id: 'provider-deepseek',
name: 'DeepSeek',
format: 'openai_chat',
},
model: 'deepseek-v4-pro',
startedAt: '2026-06-09T08:00:00.000Z',
completedAt: '2026-06-09T08:00:00.047Z',
durationMs: 47,
request: {
method: 'POST',
url: 'https://api.deepseek.com/v1/chat/completions',
headers: {
Authorization: 'Bearer sk-header-secret',
'Content-Type': 'application/json',
},
body,
},
response: {
status: 200,
headers: {
'x-request-id': 'req-742',
},
body: {
id: 'chatcmpl-742',
choices: [{ message: { role: 'assistant', content: 'ok' } }],
usage: { prompt_tokens: 31, completion_tokens: 7 },
},
},
})
const trace = await traceCaptureService.getSessionTrace('session-trace-1')
expect(trace.summary.apiCalls).toBe(1)
expect(trace.summary.failedCalls).toBe(0)
expect(trace.summary.totalDurationMs).toBe(47)
expect(trace.summary.models).toEqual([{ model: 'deepseek-v4-pro', calls: 1 }])
expect(trace.calls[0].request.headers.Authorization).toBe('[redacted]')
expect(trace.calls[0].request.body.preview).toContain('explain the failed provider response')
expect(trace.calls[0].request.body.preview).not.toContain('sk-body-secret')
expect(trace.calls[0].request.body.truncated).toBe(true)
expect(trace.calls[0].response.body.preview).toContain('chatcmpl-742')
})
test('builds stable body snapshots without throwing on non-json input', () => {
const snapshot = createTraceBodySnapshot('plain text response', { maxPreviewChars: 20 })
expect(snapshot.contentType).toBe('text')
expect(snapshot.preview).toBe('plain text response')
expect(snapshot.truncated).toBe(false)
expect(snapshot.sha256).toMatch(/^[0-9a-f]{64}$/)
})
test('skips malformed trace jsonl entries when reading a session', async () => {
const traceDir = path.join(tmpDir, 'cc-haha', 'traces')
await fs.mkdir(traceDir, { recursive: true })
await fs.writeFile(path.join(traceDir, 'session-corrupt.jsonl'), [
'not-json',
'null',
'{}',
JSON.stringify({
type: 'event',
event: {
id: 'event-valid',
sessionId: 'session-corrupt',
timestamp: '2026-06-09T08:00:00.001Z',
phase: 'api_call_started',
severity: 'info',
},
}),
JSON.stringify({
type: 'call',
record: {
id: 'call-valid',
sessionId: 'session-corrupt',
source: 'proxy',
status: 'ok',
startedAt: '2026-06-09T08:00:00.000Z',
completedAt: '2026-06-09T08:00:00.020Z',
durationMs: 20,
request: {
method: 'POST',
url: 'https://api.example.test/v1/chat/completions',
headers: {},
body: createTraceBodySnapshot({ model: 'gpt-5.5' }),
},
response: {
status: 200,
headers: {},
body: createTraceBodySnapshot({ ok: true }),
},
},
}),
].join('\n'))
const trace = await traceCaptureService.getSessionTrace('session-corrupt')
expect(trace.calls.map((call) => call.id)).toEqual(['call-valid'])
expect(trace.events.map((event) => event.id)).toEqual(['event-valid'])
expect(trace.summary.apiCalls).toBe(1)
})
test('upserts pending calls and preserves lifecycle events', async () => {
const callId = createTraceCallId()
await traceCaptureService.recordCall({
id: callId,
sessionId: 'session-trace-upsert',
source: 'anthropic',
model: 'gpt-5.5',
status: 'pending',
startedAt: '2026-06-09T08:00:00.000Z',
request: {
method: 'POST',
url: 'https://sub2api.example.test/v1/messages',
body: { model: 'gpt-5.5', messages: [{ role: 'user', content: 'pending' }] },
},
})
await traceCaptureService.recordEvent({
sessionId: 'session-trace-upsert',
callId,
phase: 'api_call_started',
source: 'anthropic',
model: 'gpt-5.5',
})
await traceCaptureService.recordCall({
id: callId,
sessionId: 'session-trace-upsert',
source: 'anthropic',
model: 'gpt-5.5',
startedAt: '2026-06-09T08:00:00.000Z',
completedAt: '2026-06-09T08:00:00.120Z',
durationMs: 120,
request: {
method: 'POST',
url: 'https://sub2api.example.test/v1/messages',
body: { model: 'gpt-5.5', messages: [{ role: 'user', content: 'pending' }] },
},
response: {
status: 200,
body: { id: 'msg-upsert' },
},
})
const trace = await traceCaptureService.getSessionTrace('session-trace-upsert')
expect(trace.summary.apiCalls).toBe(1)
expect(trace.calls).toHaveLength(1)
expect(trace.calls[0].id).toBe(callId)
expect(trace.calls[0].status).toBe('ok')
expect(trace.events).toHaveLength(1)
expect(trace.events[0]).toMatchObject({
phase: 'api_call_started',
callId,
source: 'anthropic',
})
})
test('respects managed trace capture settings before writing new records', async () => {
await updateTraceCaptureSettings({ enabled: false })
const result = await traceCaptureService.recordCall({
sessionId: 'session-trace-disabled',
source: 'proxy',
model: 'gpt-5.5',
request: {
method: 'POST',
url: 'https://api.example.test/v1/messages',
body: { model: 'gpt-5.5' },
},
})
const trace = await traceCaptureService.getSessionTrace('session-trace-disabled')
const settingsFile = JSON.parse(await fs.readFile(path.join(tmpDir, 'cc-haha', 'settings.json'), 'utf-8')) as {
traceCapture?: { enabled?: boolean }
}
expect(result).toBeNull()
expect(trace.summary.apiCalls).toBe(0)
expect(settingsFile.traceCapture?.enabled).toBe(false)
})
test('captures direct Anthropic-compatible provider calls from desktop fetch override', async () => {
const originalFetch = globalThis.fetch
const originalTraceEnv = process.env.CC_HAHA_TRACE_API_CALLS
const originalProviderId = process.env.CC_HAHA_TRACE_PROVIDER_ID
const originalProviderName = process.env.CC_HAHA_TRACE_PROVIDER_NAME
const originalProviderFormat = process.env.CC_HAHA_TRACE_PROVIDER_FORMAT
process.env.CC_HAHA_TRACE_API_CALLS = '1'
process.env.CC_HAHA_TRACE_PROVIDER_ID = 'provider-sub2api'
process.env.CC_HAHA_TRACE_PROVIDER_NAME = 'Sub2API-ChatGPT'
process.env.CC_HAHA_TRACE_PROVIDER_FORMAT = 'anthropic'
try {
globalThis.fetch = (async () => new Response(
JSON.stringify({ id: 'msg-direct-trace', content: [{ type: 'text', text: 'ok' }] }),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
)) as typeof fetch
const traceFetch = createDumpPromptsFetch('agent-direct', {
traceSessionId: 'session-direct-provider',
querySource: 'test_query',
})
await traceFetch('https://sub2api.example.test/v1/messages', {
method: 'POST',
body: JSON.stringify({ model: 'gpt-5.5', messages: [{ role: 'user', content: 'trace me' }] }),
})
const trace = await waitForTrace(
'session-direct-provider',
(snapshot) => Boolean(snapshot.calls[0]?.response) && snapshot.events.length >= 2,
)
expect(trace.summary.apiCalls).toBe(1)
expect(trace.calls[0]).toMatchObject({
source: 'anthropic',
model: 'gpt-5.5',
querySource: 'test_query',
provider: {
id: 'provider-sub2api',
name: 'Sub2API-ChatGPT',
format: 'anthropic',
},
})
expect(trace.calls[0].request.body.preview).toContain('trace me')
expect(trace.calls[0].response.body.preview).toContain('msg-direct-trace')
expect(trace.events.map((event) => event.phase)).toEqual(['api_call_started', 'api_call_completed'])
} finally {
globalThis.fetch = originalFetch
if (originalTraceEnv === undefined) delete process.env.CC_HAHA_TRACE_API_CALLS
else process.env.CC_HAHA_TRACE_API_CALLS = originalTraceEnv
if (originalProviderId === undefined) delete process.env.CC_HAHA_TRACE_PROVIDER_ID
else process.env.CC_HAHA_TRACE_PROVIDER_ID = originalProviderId
if (originalProviderName === undefined) delete process.env.CC_HAHA_TRACE_PROVIDER_NAME
else process.env.CC_HAHA_TRACE_PROVIDER_NAME = originalProviderName
if (originalProviderFormat === undefined) delete process.env.CC_HAHA_TRACE_PROVIDER_FORMAT
else process.env.CC_HAHA_TRACE_PROVIDER_FORMAT = originalProviderFormat
}
})
test('captures direct provider fetch failures without changing thrown behavior', async () => {
const originalFetch = globalThis.fetch
const originalTraceEnv = process.env.CC_HAHA_TRACE_API_CALLS
process.env.CC_HAHA_TRACE_API_CALLS = '1'
try {
globalThis.fetch = (async () => {
throw new Error('network down for trace')
}) as typeof fetch
const traceFetch = createDumpPromptsFetch('agent-direct-fail', {
traceSessionId: 'session-direct-provider-fail',
querySource: 'test_query',
})
await expect(traceFetch('https://sub2api.example.test/v1/messages', {
method: 'POST',
body: JSON.stringify({ model: 'gpt-5.5', messages: [{ role: 'user', content: 'trace failure' }] }),
})).rejects.toThrow('network down for trace')
const trace = await waitForTrace(
'session-direct-provider-fail',
(snapshot) => Boolean(snapshot.calls[0]?.error) && snapshot.events.length >= 2,
)
expect(trace.summary.apiCalls).toBe(1)
expect(trace.summary.failedCalls).toBe(1)
expect(trace.calls[0]).toMatchObject({
source: 'anthropic',
model: 'gpt-5.5',
status: 'error',
error: {
name: 'Error',
message: 'network down for trace',
},
})
expect(trace.calls[0].request.body.preview).toContain('trace failure')
expect(trace.events.map((event) => event.phase)).toEqual(['api_call_started', 'api_call_failed'])
} finally {
globalThis.fetch = originalFetch
if (originalTraceEnv === undefined) delete process.env.CC_HAHA_TRACE_API_CALLS
else process.env.CC_HAHA_TRACE_API_CALLS = originalTraceEnv
}
})
test('passes session id to local provider proxy without duplicating client-side trace', async () => {
const originalFetch = globalThis.fetch
const originalTraceEnv = process.env.CC_HAHA_TRACE_API_CALLS
let seenHeader: string | null = null
process.env.CC_HAHA_TRACE_API_CALLS = '1'
try {
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
seenHeader = new Headers(init?.headers).get('x-claude-code-session-id')
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
const traceFetch = createDumpPromptsFetch('agent-proxy', {
traceSessionId: 'session-local-proxy',
querySource: 'test_query',
})
await traceFetch('http://127.0.0.1:3456/proxy/providers/provider-1/v1/messages', {
method: 'POST',
body: JSON.stringify({ model: 'deepseek-v4-pro', messages: [{ role: 'user', content: 'proxy trace' }] }),
})
expect(seenHeader).toBe('session-local-proxy')
const trace = await traceCaptureService.getSessionTrace('session-local-proxy')
expect(trace.summary.apiCalls).toBe(0)
} finally {
globalThis.fetch = originalFetch
if (originalTraceEnv === undefined) delete process.env.CC_HAHA_TRACE_API_CALLS
else process.env.CC_HAHA_TRACE_API_CALLS = originalTraceEnv
}
})
})
describe('session trace API', () => {
test('returns an empty trace when no calls were captured for the session', async () => {
const req = new Request('http://localhost:3456/api/sessions/missing-session/trace')
const url = new URL(req.url)
const res = await handleApiRequest(req, url)
const body = await res.json() as Awaited<ReturnType<typeof traceCaptureService.getSessionTrace>> & { session: unknown }
expect(res.status).toBe(200)
expect(body.sessionId).toBe('missing-session')
expect(body.session).toBeNull()
expect(body.summary.apiCalls).toBe(0)
expect(body.calls).toEqual([])
expect(body.events).toEqual([])
})
test('lists trace sessions with storage metadata and managed settings', async () => {
await traceCaptureService.recordCall({
sessionId: 'session-list-trace',
source: 'proxy',
model: 'gpt-5.5',
startedAt: '2026-06-09T08:00:00.000Z',
completedAt: '2026-06-09T08:00:00.015Z',
durationMs: 15,
request: {
method: 'POST',
url: 'https://api.example.test/v1/messages',
body: { model: 'gpt-5.5' },
},
response: {
status: 200,
body: { ok: true },
},
})
const req = new Request('http://localhost:3456/api/traces')
const res = await handleApiRequest(req, new URL(req.url))
const body = await res.json() as {
traces: Array<{ sessionId: string; summary: { apiCalls: number }; fileSize: number }>
total: number
storageDir: string
settings: { enabled: boolean; storageDir: string }
}
expect(res.status).toBe(200)
expect(body.total).toBe(1)
expect(body.traces[0].sessionId).toBe('session-list-trace')
expect(body.traces[0].summary.apiCalls).toBe(1)
expect(body.traces[0].fileSize).toBeGreaterThan(0)
expect(body.storageDir).toBe(path.join(tmpDir, 'cc-haha', 'traces'))
expect(body.settings).toEqual({
enabled: true,
storageDir: path.join(tmpDir, 'cc-haha', 'traces'),
})
})
test('searches trace sessions by session title and project path before paginating', async () => {
await traceCaptureService.recordCall({
sessionId: 'session-title-alpha',
source: 'proxy',
model: 'gpt-5.5',
startedAt: '2026-06-09T08:00:00.000Z',
completedAt: '2026-06-09T08:00:00.015Z',
durationMs: 15,
request: {
method: 'POST',
url: 'https://api.example.test/v1/messages',
body: { model: 'gpt-5.5' },
},
response: {
status: 200,
body: { ok: true },
},
})
await traceCaptureService.recordCall({
sessionId: 'session-title-beta',
source: 'proxy',
model: 'gpt-5.5',
startedAt: '2026-06-09T08:00:01.000Z',
completedAt: '2026-06-09T08:00:01.015Z',
durationMs: 15,
request: {
method: 'POST',
url: 'https://api.example.test/v1/messages',
body: { model: 'gpt-5.5' },
},
response: {
status: 200,
body: { ok: true },
},
})
const originalGetSession = sessionService.getSession
sessionService.getSession = (async (sessionId: string) => {
if (sessionId === 'session-title-alpha') {
return {
id: sessionId,
title: 'Debug stuck checkout agent',
createdAt: '2026-06-09T08:00:00.000Z',
modifiedAt: '2026-06-09T08:00:00.015Z',
messageCount: 2,
projectPath: '/tmp/checkout',
projectRoot: '/tmp/checkout',
workDir: '/tmp/checkout',
workDirExists: true,
messages: [],
}
}
if (sessionId === 'session-title-beta') {
return {
id: sessionId,
title: 'Unrelated model run',
createdAt: '2026-06-09T08:00:01.000Z',
modifiedAt: '2026-06-09T08:00:01.015Z',
messageCount: 2,
projectPath: '/tmp/other',
projectRoot: '/tmp/other',
workDir: '/tmp/other',
workDirExists: true,
messages: [],
}
}
return null
}) as typeof sessionService.getSession
try {
const titleReq = new Request('http://localhost:3456/api/traces?q=stuck%20agent&limit=10&offset=0')
const titleRes = await handleApiRequest(titleReq, new URL(titleReq.url))
const titleBody = await titleRes.json() as {
traces: Array<{ sessionId: string; session: { title: string; projectPath: string } | null }>
total: number
}
expect(titleRes.status).toBe(200)
expect(titleBody.total).toBe(1)
expect(titleBody.traces.map((trace) => trace.sessionId)).toEqual(['session-title-alpha'])
expect(titleBody.traces[0].session?.title).toBe('Debug stuck checkout agent')
const pathReq = new Request('http://localhost:3456/api/traces?q=checkout&limit=10&offset=0')
const pathRes = await handleApiRequest(pathReq, new URL(pathReq.url))
const pathBody = await pathRes.json() as {
traces: Array<{ sessionId: string; session: { projectPath: string } | null }>
total: number
}
expect(pathRes.status).toBe(200)
expect(pathBody.total).toBe(1)
expect(pathBody.traces.map((trace) => trace.sessionId)).toEqual(['session-title-alpha'])
expect(pathBody.traces[0].session?.projectPath).toBe('/tmp/checkout')
const missReq = new Request('http://localhost:3456/api/traces?q=missing-title&limit=10&offset=0')
const missRes = await handleApiRequest(missReq, new URL(missReq.url))
const missBody = await missRes.json() as {
traces: Array<{ sessionId: string }>
total: number
}
expect(missRes.status).toBe(200)
expect(missBody.total).toBe(0)
expect(missBody.traces).toEqual([])
} finally {
sessionService.getSession = originalGetSession
}
})
})

View File

@ -7,6 +7,7 @@
* GET /api/sessions
* GET /api/sessions/:id
* GET /api/sessions/:id/messages
* GET /api/sessions/:id/trace trace
* GET /api/sessions/:id/turn-checkpoints checkpoint
* GET /api/sessions/:id/turn-checkpoints/diff checkpoint diff
* POST /api/sessions
@ -39,6 +40,7 @@ import {
SessionBranchingError,
} from '../../utils/sessionBranching.js'
import { registerFilesystemAccessRoot } from '../services/filesystemAccessRoots.js'
import { traceCaptureService } from '../services/traceCaptureService.js'
const workspaceService = new WorkspaceService(
async (sessionId) => (
@ -110,6 +112,16 @@ export async function handleSessionsApi(
return await getSessionMessages(sessionId)
}
if (subResource === 'trace') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await getSessionTrace(sessionId)
}
if (subResource === 'git-info') {
if (req.method !== 'GET') {
return Response.json(
@ -250,6 +262,24 @@ async function getSessionMessages(sessionId: string): Promise<Response> {
return Response.json({ messages, taskNotifications })
}
async function getSessionTrace(sessionId: string): Promise<Response> {
const [trace, session] = await Promise.all([
traceCaptureService.getSessionTrace(sessionId),
sessionService.getSession(sessionId).catch(() => null),
])
return Response.json({
...trace,
session: session
? {
id: session.id,
title: session.title,
projectPath: session.projectPath,
workDir: session.workDir,
}
: null,
})
}
async function handleSessionWorkspaceRoute(
sessionId: string,
url: URL,

182
src/server/api/traces.ts Normal file
View File

@ -0,0 +1,182 @@
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { sessionService } from '../services/sessionService.js'
import {
readTraceCaptureSettings,
traceCaptureService,
updateTraceCaptureSettings,
type TraceSessionFileItem,
type TraceSessionListItem,
} from '../services/traceCaptureService.js'
export type TraceSessionListApiItem = TraceSessionListItem & {
session: {
id: string
title: string
projectPath: string
workDir: string
} | null
}
function methodNotAllowed(method: string, route: string): ApiError {
return new ApiError(405, `Method ${method} not allowed on ${route}`, 'METHOD_NOT_ALLOWED')
}
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
try {
const body = await req.json()
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw ApiError.badRequest('Invalid JSON body')
}
return body as Record<string, unknown>
} catch (error) {
if (error instanceof ApiError) throw error
throw ApiError.badRequest('Invalid JSON body')
}
}
export async function handleTracesApi(
req: Request,
url: URL,
segments: string[],
): Promise<Response> {
try {
const sub = segments[2]
switch (sub) {
case undefined:
if (req.method !== 'GET') throw methodNotAllowed(req.method, '/api/traces')
return await listTraces(url)
case 'settings':
if (req.method === 'GET') {
return Response.json(await readTraceCaptureSettings())
}
if (req.method === 'PUT') {
const body = await parseJsonBody(req)
const input: { enabled?: boolean } = {}
if (typeof body.enabled === 'boolean') input.enabled = body.enabled
return Response.json(await updateTraceCaptureSettings(input))
}
throw methodNotAllowed(req.method, '/api/traces/settings')
default:
throw ApiError.notFound(`Unknown traces endpoint: ${sub}`)
}
} catch (error) {
return errorResponse(error)
}
}
async function listTraces(url: URL): Promise<Response> {
const limit = parseTraceListLimit(url.searchParams.get('limit'))
const offset = parseTraceListOffset(url.searchParams.get('offset'))
const query = url.searchParams.get('q')?.trim() || ''
if (query) {
return listSearchedTraces(query, limit, offset)
}
const result = await traceCaptureService.listSessionTraces({ limit, offset })
const decorated = await Promise.all(result.traces.map(decorateTraceListItem))
return Response.json({
...result,
traces: decorated,
})
}
async function listSearchedTraces(query: string, limit: number, offset: number): Promise<Response> {
const fileResult = await traceCaptureService.listSessionTraceFiles()
const candidates = await Promise.all(fileResult.files.map(decorateTraceFileCandidate))
const filtered = candidates.filter((item) => traceListItemMatchesQuery(item, query))
const pageCandidates = filtered.slice(offset, offset + limit)
const pageSessionIds = pageCandidates.map((item) => item.sessionId)
if (pageSessionIds.length === 0) {
return Response.json({
traces: [],
total: filtered.length,
storageDir: fileResult.storageDir,
settings: fileResult.settings,
})
}
const pageResult = await traceCaptureService.listSessionTraces({
sessionIds: pageSessionIds,
limit: pageSessionIds.length,
offset: 0,
})
const itemsBySessionId = new Map(pageResult.traces.map((item) => [item.sessionId, item]))
const traces = pageCandidates.flatMap((candidate) => {
const trace = itemsBySessionId.get(candidate.sessionId)
if (!trace) return []
return [{
...trace,
session: candidate.session,
}]
})
return Response.json({
...pageResult,
total: filtered.length,
storageDir: fileResult.storageDir,
settings: fileResult.settings,
traces,
})
}
async function decorateTraceListItem(item: TraceSessionListItem): Promise<TraceSessionListApiItem> {
const session = await sessionService.getSession(item.sessionId).catch(() => null)
return {
...item,
session: session
? {
id: session.id,
title: session.title,
projectPath: session.projectPath,
workDir: session.workDir,
}
: null,
}
}
async function decorateTraceFileCandidate(item: TraceSessionFileItem): Promise<Pick<TraceSessionListApiItem, 'sessionId' | 'session'>> {
const session = await sessionService.getSession(item.sessionId).catch(() => null)
return {
sessionId: item.sessionId,
session: session
? {
id: session.id,
title: session.title,
projectPath: session.projectPath,
workDir: session.workDir,
}
: null,
}
}
function traceListItemMatchesQuery(item: Pick<TraceSessionListApiItem, 'sessionId' | 'session'>, query: string): boolean {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
if (terms.length === 0) return true
const haystack = [
item.sessionId,
item.session?.title,
item.session?.projectPath,
item.session?.workDir,
]
.filter((value): value is string => Boolean(value))
.join('\n')
.toLowerCase()
return terms.every((term) => haystack.includes(term))
}
function parseTraceListLimit(value: string | null): number {
const parsed = Number.parseInt(value || '50', 10)
if (!Number.isFinite(parsed) || parsed <= 0) return 50
return Math.min(Math.max(Math.round(parsed), 1), 200)
}
function parseTraceListOffset(value: string | null): number {
const parsed = Number.parseInt(value || '0', 10)
if (!Number.isFinite(parsed) || parsed <= 0) return 0
return parsed
}

View File

@ -22,11 +22,42 @@ import type { AnthropicRequest } from './transform/types.js'
import { getProxyFetchOptions } from '../../utils/proxy.js'
import { getManualNetworkProxyUrl, loadNetworkSettings } from '../services/networkSettings.js'
import { normalizeModelStringForAPI } from '../../utils/model/model.js'
import {
createTraceCallId,
createTraceBodySnapshot,
traceCaptureService,
type TraceBodySnapshot,
type TraceProviderInfo,
} from '../services/traceCaptureService.js'
const providerService = new ProviderService()
type ProxyFetchOptions = ReturnType<typeof getProxyFetchOptions>
type UpstreamRequestInit = RequestInit & ProxyFetchOptions
type ProxyTraceContext = {
sessionId: string
provider: TraceProviderInfo
anthropicRequest: AnthropicRequest
}
const TRACE_RECORDED_ERROR_MARKER = Symbol('cc-haha-trace-recorded-error')
function markTraceErrorRecorded(error: unknown): void {
if (error && typeof error === 'object') {
try {
Object.defineProperty(error, TRACE_RECORDED_ERROR_MARKER, {
value: true,
enumerable: false,
})
} catch {
// Best effort only; proxy error handling must not depend on trace metadata.
}
}
}
function wasTraceErrorRecorded(error: unknown): boolean {
return Boolean(error && typeof error === 'object' && (error as Record<symbol, unknown>)[TRACE_RECORDED_ERROR_MARKER])
}
function createTimeoutController(timeoutMs: number): {
signal: AbortSignal
@ -189,14 +220,26 @@ export async function handleProxyRequest(req: Request, url: URL): Promise<Respon
const baseUrl = config.baseUrl.replace(/\/+$/, '')
const networkSettings = await loadNetworkSettings()
const proxyUrl = getManualNetworkProxyUrl(networkSettings)
const traceContext = buildProxyTraceContext(req, config, body)
try {
if (config.apiFormat === 'openai_chat') {
return await handleOpenaiChat(body, baseUrl, config.apiKey, isStream, networkSettings.aiRequestTimeoutMs, proxyUrl)
return await handleOpenaiChat(body, baseUrl, config.apiKey, isStream, networkSettings.aiRequestTimeoutMs, proxyUrl, traceContext)
} else {
return await handleOpenaiResponses(body, baseUrl, config.apiKey, isStream, networkSettings.aiRequestTimeoutMs, proxyUrl)
return await handleOpenaiResponses(body, baseUrl, config.apiKey, isStream, networkSettings.aiRequestTimeoutMs, proxyUrl, traceContext)
}
} catch (err) {
if (traceContext && !wasTraceErrorRecorded(err)) {
void recordProxyTrace({
context: traceContext,
model: body.model,
upstreamUrl: baseUrl,
upstreamRequest: null,
startedAt: new Date().toISOString(),
startedAtMs: Date.now(),
error: err,
}).catch(() => {})
}
console.error('[Proxy] Upstream request failed:', err)
return Response.json(
{
@ -218,6 +261,7 @@ async function handleOpenaiChat(
isStream: boolean,
aiRequestTimeoutMs: number,
proxyUrl: string | undefined,
traceContext: ProxyTraceContext | null,
): Promise<Response> {
const deepSeekCompatible = shouldUseDeepSeekReasoningCompat(baseUrl)
const transformed = anthropicToOpenaiChat(body, {
@ -227,33 +271,90 @@ async function handleOpenaiChat(
})
const url = `${baseUrl}/v1/chat/completions`
const proxyOptions = getProxyFetchOptions({ proxyUrl })
const startedAtMs = Date.now()
const startedAt = new Date(startedAtMs).toISOString()
const traceCallId = traceContext
? startProxyTraceCall({
context: traceContext,
model: body.model,
upstreamUrl: url,
upstreamRequest: transformed,
startedAt,
})
: undefined
const upstream = await fetchUpstreamWithTimeout(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)),
...proxyOptions,
}, aiRequestTimeoutMs, isStream)
let upstream: Response
try {
upstream = await fetchUpstreamWithTimeout(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)),
...proxyOptions,
}, aiRequestTimeoutMs, isStream)
} catch (err) {
if (traceContext) {
await recordProxyTrace({
callId: traceCallId,
context: traceContext,
model: body.model,
upstreamUrl: url,
upstreamRequest: transformed,
startedAt,
startedAtMs,
error: err,
})
markTraceErrorRecorded(err)
}
throw err
}
if (!upstream.ok) {
const errText = await upstream.text().catch(() => '')
return Response.json(
{
type: 'error',
error: {
type: 'api_error',
message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`,
},
const errorBody = {
type: 'error',
error: {
type: 'api_error',
message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`,
},
}
if (traceContext) {
await recordProxyTrace({
context: traceContext,
callId: traceCallId,
model: body.model,
upstreamUrl: url,
upstreamRequest: transformed,
startedAt,
startedAtMs,
responseStatus: upstream.status,
upstreamResponseBody: errText,
anthropicResponseBody: errorBody,
responseHeaders: upstream.headers,
})
}
return Response.json(
errorBody,
{ status: upstream.status },
)
}
if (isStream) {
if (!upstream.body) {
if (traceContext) {
await recordProxyTrace({
callId: traceCallId,
context: traceContext,
model: body.model,
upstreamUrl: url,
upstreamRequest: transformed,
startedAt,
startedAtMs,
error: new Error('Upstream returned no body for stream'),
})
}
return Response.json(
{ type: 'error', error: { type: 'api_error', message: 'Upstream returned no body for stream' } },
{ status: 502 },
@ -261,7 +362,24 @@ async function handleOpenaiChat(
}
const upstreamBody = withStreamIdleTimeout(upstream.body, aiRequestTimeoutMs)
const anthropicStream = openaiChatStreamToAnthropic(upstreamBody, body.model)
return new Response(anthropicStream, {
const tracedStream = traceContext
? captureTraceStream(anthropicStream, async (bodySnapshot, error) => {
await recordProxyTrace({
callId: traceCallId,
context: traceContext,
model: body.model,
upstreamUrl: url,
upstreamRequest: transformed,
startedAt,
startedAtMs,
responseStatus: 200,
responseBodySnapshot: bodySnapshot,
responseHeaders: upstream.headers,
...(error ? { error } : {}),
})
})
: anthropicStream
return new Response(tracedStream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
@ -274,6 +392,21 @@ async function handleOpenaiChat(
// Non-streaming
const responseBody = await upstream.json()
const anthropicResponse = openaiChatToAnthropic(responseBody, body.model)
if (traceContext) {
await recordProxyTrace({
callId: traceCallId,
context: traceContext,
model: body.model,
upstreamUrl: url,
upstreamRequest: transformed,
startedAt,
startedAtMs,
responseStatus: 200,
upstreamResponseBody: responseBody,
anthropicResponseBody: anthropicResponse,
responseHeaders: upstream.headers,
})
}
return Response.json(anthropicResponse)
}
@ -295,37 +428,95 @@ async function handleOpenaiResponses(
isStream: boolean,
aiRequestTimeoutMs: number,
proxyUrl: string | undefined,
traceContext: ProxyTraceContext | null,
): Promise<Response> {
const transformed = anthropicToOpenaiResponses(body)
const url = `${baseUrl}/v1/responses`
const proxyOptions = getProxyFetchOptions({ proxyUrl })
const startedAtMs = Date.now()
const startedAt = new Date(startedAtMs).toISOString()
const traceCallId = traceContext
? startProxyTraceCall({
context: traceContext,
model: body.model,
upstreamUrl: url,
upstreamRequest: transformed,
startedAt,
})
: undefined
const upstream = await fetchUpstreamWithTimeout(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)),
...proxyOptions,
}, aiRequestTimeoutMs, isStream)
let upstream: Response
try {
upstream = await fetchUpstreamWithTimeout(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)),
...proxyOptions,
}, aiRequestTimeoutMs, isStream)
} catch (err) {
if (traceContext) {
await recordProxyTrace({
callId: traceCallId,
context: traceContext,
model: body.model,
upstreamUrl: url,
upstreamRequest: transformed,
startedAt,
startedAtMs,
error: err,
})
markTraceErrorRecorded(err)
}
throw err
}
if (!upstream.ok) {
const errText = await upstream.text().catch(() => '')
return Response.json(
{
type: 'error',
error: {
type: 'api_error',
message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`,
},
const errorBody = {
type: 'error',
error: {
type: 'api_error',
message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`,
},
}
if (traceContext) {
await recordProxyTrace({
context: traceContext,
callId: traceCallId,
model: body.model,
upstreamUrl: url,
upstreamRequest: transformed,
startedAt,
startedAtMs,
responseStatus: upstream.status,
upstreamResponseBody: errText,
anthropicResponseBody: errorBody,
responseHeaders: upstream.headers,
})
}
return Response.json(
errorBody,
{ status: upstream.status },
)
}
if (isStream) {
if (!upstream.body) {
if (traceContext) {
await recordProxyTrace({
callId: traceCallId,
context: traceContext,
model: body.model,
upstreamUrl: url,
upstreamRequest: transformed,
startedAt,
startedAtMs,
error: new Error('Upstream returned no body for stream'),
})
}
return Response.json(
{ type: 'error', error: { type: 'api_error', message: 'Upstream returned no body for stream' } },
{ status: 502 },
@ -333,7 +524,24 @@ async function handleOpenaiResponses(
}
const upstreamBody = withStreamIdleTimeout(upstream.body, aiRequestTimeoutMs)
const anthropicStream = openaiResponsesStreamToAnthropic(upstreamBody, body.model)
return new Response(anthropicStream, {
const tracedStream = traceContext
? captureTraceStream(anthropicStream, async (bodySnapshot, error) => {
await recordProxyTrace({
callId: traceCallId,
context: traceContext,
model: body.model,
upstreamUrl: url,
upstreamRequest: transformed,
startedAt,
startedAtMs,
responseStatus: 200,
responseBodySnapshot: bodySnapshot,
responseHeaders: upstream.headers,
...(error ? { error } : {}),
})
})
: anthropicStream
return new Response(tracedStream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
@ -346,5 +554,242 @@ async function handleOpenaiResponses(
// Non-streaming
const responseBody = await upstream.json()
const anthropicResponse = openaiResponsesToAnthropic(responseBody, body.model)
if (traceContext) {
await recordProxyTrace({
callId: traceCallId,
context: traceContext,
model: body.model,
upstreamUrl: url,
upstreamRequest: transformed,
startedAt,
startedAtMs,
responseStatus: 200,
upstreamResponseBody: responseBody,
anthropicResponseBody: anthropicResponse,
responseHeaders: upstream.headers,
})
}
return Response.json(anthropicResponse)
}
function buildProxyTraceContext(
req: Request,
config: { id: string; name: string; apiFormat: string },
anthropicRequest: AnthropicRequest,
): ProxyTraceContext | null {
const sessionId = req.headers.get('x-claude-code-session-id')?.trim()
if (!sessionId) return null
return {
sessionId,
provider: {
id: config.id,
name: config.name,
format: config.apiFormat,
},
anthropicRequest,
}
}
function createProxyTraceRequestBody(context: ProxyTraceContext, upstreamRequest: unknown): Record<string, unknown> {
return upstreamRequest
? {
anthropic: context.anthropicRequest,
upstream: upstreamRequest,
}
: {
anthropic: context.anthropicRequest,
}
}
function startProxyTraceCall({
context,
model,
upstreamUrl,
upstreamRequest,
startedAt,
}: {
context: ProxyTraceContext
model: string
upstreamUrl: string
upstreamRequest: unknown
startedAt: string
}): string {
const callId = createTraceCallId()
void traceCaptureService.recordCall({
id: callId,
sessionId: context.sessionId,
source: 'proxy',
provider: context.provider,
model,
status: 'pending',
startedAt,
request: {
method: 'POST',
url: upstreamUrl,
bodySnapshot: createTraceBodySnapshot({
pending: true,
note: 'proxy request body captured on call completion',
}),
},
metadata: {
phase: 'upstream_fetch_started',
},
})
void traceCaptureService.recordEvent({
sessionId: context.sessionId,
callId,
source: 'proxy',
provider: context.provider,
model,
timestamp: startedAt,
phase: 'upstream_fetch_started',
severity: 'info',
title: 'Upstream fetch started',
metadata: {
url: upstreamUrl,
},
})
return callId
}
async function recordProxyTrace({
callId,
context,
model,
upstreamUrl,
upstreamRequest,
startedAt,
startedAtMs,
responseStatus,
upstreamResponseBody,
anthropicResponseBody,
responseBodySnapshot,
responseHeaders,
error,
}: {
callId?: string
context: ProxyTraceContext
model: string
upstreamUrl: string
upstreamRequest: unknown
startedAt: string
startedAtMs: number
responseStatus?: number
upstreamResponseBody?: unknown
anthropicResponseBody?: unknown
responseBodySnapshot?: TraceBodySnapshot
responseHeaders?: Headers
error?: unknown
}): Promise<void> {
const completedAt = new Date().toISOString()
const requestBody = createProxyTraceRequestBody(context, upstreamRequest)
const responseBody = anthropicResponseBody === undefined && upstreamResponseBody === undefined
? undefined
: {
...(upstreamResponseBody !== undefined ? { upstream: upstreamResponseBody } : {}),
...(anthropicResponseBody !== undefined ? { anthropic: anthropicResponseBody } : {}),
}
await traceCaptureService.recordCall({
...(callId ? { id: callId } : {}),
sessionId: context.sessionId,
source: 'proxy',
provider: context.provider,
model,
startedAt,
completedAt,
durationMs: Date.now() - startedAtMs,
request: {
method: 'POST',
url: upstreamUrl,
body: requestBody,
},
...(responseStatus !== undefined
? {
response: {
status: responseStatus,
headers: responseHeaders,
...(responseBodySnapshot ? { bodySnapshot: responseBodySnapshot } : { body: responseBody }),
},
}
: {}),
...(error ? { error } : {}),
metadata: {
phase: error ? 'upstream_fetch_failed' : 'upstream_fetch_completed',
},
})
await traceCaptureService.recordEvent({
sessionId: context.sessionId,
...(callId ? { callId } : {}),
source: 'proxy',
provider: context.provider,
model,
timestamp: completedAt,
phase: error ? 'upstream_fetch_failed' : 'upstream_fetch_completed',
severity: error ? 'error' : responseStatus !== undefined && responseStatus >= 400 ? 'warning' : 'info',
title: error ? 'Upstream fetch failed' : 'Upstream fetch completed',
message: error instanceof Error ? error.message : error ? String(error) : undefined,
metadata: {
status: responseStatus,
url: upstreamUrl,
},
})
}
function captureTraceStream(
stream: ReadableStream<Uint8Array>,
onComplete: (snapshot: TraceBodySnapshot, error?: unknown) => Promise<void>,
): ReadableStream<Uint8Array> {
const decoder = new TextDecoder()
let captured = ''
let bytes = 0
let truncated = false
let finalized = false
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null
const captureChunk = (chunk: Uint8Array) => {
bytes += chunk.byteLength
if (bytes <= 256 * 1024) {
captured += decoder.decode(chunk, { stream: true })
} else {
truncated = true
}
}
const finalize = async (error?: unknown) => {
if (finalized) return
finalized = true
captured += decoder.decode()
const snapshot = createTraceBodySnapshot(captured, { alreadyTruncated: truncated })
await onComplete(snapshot, error).catch(() => {})
}
return new ReadableStream<Uint8Array>({
async start(controller) {
reader = stream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
captureChunk(value)
controller.enqueue(value)
}
await finalize()
controller.close()
} catch (err) {
await finalize(err)
controller.error(err)
} finally {
reader?.releaseLock()
reader = null
}
},
async cancel(reason) {
const error = reason instanceof Error
? reason
: new Error(reason ? `Stream cancelled: ${String(reason)}` : 'Stream cancelled')
await finalize(error)
await reader?.cancel(reason).catch(() => undefined)
},
})
}

View File

@ -27,6 +27,7 @@ import { handleActivityStatsApi } from './api/activityStats.js'
import { handleOpenTargetsApi } from './api/open-targets.js'
import { handleMemoryApi } from './api/memory.js'
import { handleDesktopUiApi } from './api/desktop-ui.js'
import { handleTracesApi } from './api/traces.js'
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
const path = url.pathname
@ -119,6 +120,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
case 'desktop-ui':
return handleDesktopUiApi(req, url, segments)
case 'traces':
return handleTracesApi(req, url, segments)
case 'filesystem':
return handleFilesystemRoute(url.pathname, url)

View File

@ -32,6 +32,7 @@ import { sanitizePath } from '../../utils/path.js'
import { getProcessEnvWithTerminalShellEnvironment } from '../../utils/terminalShellEnvironment.js'
import { attributionHeaderEnvForModel } from './attributionHeaderPolicy.js'
import { buildNetworkEnvironment, loadNetworkSettings } from './networkSettings.js'
import { readTraceCaptureSettings } from './traceCaptureService.js'
import { logError } from '../../utils/log.js'
import {
createImageMetadataText,
@ -1046,6 +1047,9 @@ export class ConversationService {
if (options?.resumeInterruptedTurn === false) {
delete cleanEnv.CLAUDE_CODE_RESUME_INTERRUPTED_TURN
}
delete cleanEnv.CC_HAHA_TRACE_PROVIDER_ID
delete cleanEnv.CC_HAHA_TRACE_PROVIDER_NAME
delete cleanEnv.CC_HAHA_TRACE_PROVIDER_FORMAT
if (this.shouldStripInheritedProviderEnv(options?.providerId)) {
for (const key of PROVIDER_ENV_KEYS) {
delete cleanEnv[key]
@ -1062,11 +1066,15 @@ export class ConversationService {
}
}
const explicitProviderEnv =
const explicitProvider =
typeof options?.providerId === 'string'
? await this.providerService.getProviderRuntimeEnv(options.providerId)
? await this.providerService.getProvider(options.providerId)
: null
const explicitProviderEnv = explicitProvider
? await this.providerService.getProviderRuntimeEnv(explicitProvider.id)
: null
const networkEnv = buildNetworkEnvironment(await loadNetworkSettings())
const traceCaptureEnabled = (await readTraceCaptureSettings()).enabled
if (explicitProviderEnv && options?.model?.trim()) {
explicitProviderEnv.ANTHROPIC_MODEL = options.model.trim()
}
@ -1096,6 +1104,16 @@ export class ConversationService {
...(sdkUrl
? { CC_HAHA_COMPUTER_USE_HOST_BUNDLE_ID: 'com.claude-code-haha.desktop' }
: {}),
...(sdkUrl && traceCaptureEnabled
? { CC_HAHA_TRACE_API_CALLS: '1' }
: {}),
...(sdkUrl && traceCaptureEnabled && explicitProvider
? {
CC_HAHA_TRACE_PROVIDER_ID: explicitProvider.id,
CC_HAHA_TRACE_PROVIDER_NAME: explicitProvider.name,
CC_HAHA_TRACE_PROVIDER_FORMAT: explicitProvider.apiFormat ?? 'anthropic',
}
: {}),
...(desktopServerUrl
? { CC_HAHA_DESKTOP_SERVER_URL: desktopServerUrl }
: {}),

View File

@ -375,6 +375,8 @@ export class ProviderService {
// --- Proxy support ---
async getProviderForProxy(providerId?: string): Promise<{
id: string
name: string
baseUrl: string
apiKey: string
apiFormat: ApiFormat
@ -385,6 +387,8 @@ export class ProviderService {
}
const provider = await this.getProvider(providerId)
return {
id: provider.id,
name: provider.name,
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
apiFormat: provider.apiFormat ?? 'anthropic',
@ -399,6 +403,8 @@ export class ProviderService {
const provider = await this.getProvider(index.activeId).catch(() => null)
if (!provider) return null
return {
id: provider.id,
name: provider.name,
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
apiFormat: provider.apiFormat ?? 'anthropic',

View File

@ -0,0 +1,29 @@
export {
clearTraceCaptureStateForTests,
createTraceCallId,
createTraceBodySnapshot,
getTraceStorageDir,
isTraceCaptureEnabled,
readTraceCaptureSettings,
readResponseTraceSnapshot,
shouldCaptureApiTrace,
traceCaptureService,
updateTraceCaptureSettings,
} from '../../services/api/traceCapture.js'
export type {
RecordTraceCallInput,
RecordTraceEventInput,
TraceBodySnapshot,
TraceCaptureSettings,
TraceCallStatus,
TraceCallRecord,
TraceEventRecord,
TraceEventSeverity,
TraceProviderInfo,
TraceSession,
TraceSessionFileItem,
TraceSessionFileList,
TraceSessionList,
TraceSessionListItem,
TraceSessionSummary,
} from '../../services/api/traceCapture.js'

View File

@ -5,6 +5,16 @@ import { dirname, join } from 'path'
import { getSessionId } from 'src/bootstrap/state.js'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'
import {
createTraceCallId,
createTraceBodySnapshot,
readResponseTraceSnapshot,
shouldCaptureApiTrace,
traceCaptureService,
} from './traceCapture.js'
import type { TraceBodySnapshot, TraceProviderInfo } from './traceCapture.js'
const TRACE_SESSION_HEADER = 'x-claude-code-session-id'
function hashString(str: string): string {
return createHash('sha256').update(str).digest('hex')
@ -45,6 +55,35 @@ export function clearAllDumpState(): void {
dumpState.clear()
}
function getRequestUrl(input: RequestInfo | URL): string {
return input instanceof Request ? input.url : String(input)
}
function isLocalProviderProxyUrl(value: string): boolean {
try {
const url = new URL(value)
return (
(url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '::1') &&
url.pathname.startsWith('/proxy/')
)
} catch {
return false
}
}
function getTraceProviderFromEnv(): TraceProviderInfo | undefined {
const id = process.env.CC_HAHA_TRACE_PROVIDER_ID?.trim()
const name = process.env.CC_HAHA_TRACE_PROVIDER_NAME?.trim()
const format = process.env.CC_HAHA_TRACE_PROVIDER_FORMAT?.trim()
if (!id && !name && !format) return undefined
return {
id: id || null,
name: name || id || 'Provider',
format: format || 'anthropic',
}
}
export function addApiRequestToCache(requestData: unknown): void {
if (process.env.USER_TYPE !== 'ant') return
cachedApiRequests.push({
@ -143,10 +182,28 @@ function dumpRequest(
}
}
function createRequestPendingSnapshot(body: unknown): TraceBodySnapshot {
if (typeof body === 'string') {
return createTraceBodySnapshot(body.slice(0, 4096), {
alreadyTruncated: body.length > 4096,
})
}
return createTraceBodySnapshot({
pending: true,
note: 'request body captured on call completion',
})
}
export function createDumpPromptsFetch(
agentIdOrSessionId: string,
options?: {
traceSessionId?: string
querySource?: string
},
): ClientOptions['fetch'] {
const filePath = getDumpPromptsPath(agentIdOrSessionId)
const traceEnabled = shouldCaptureApiTrace()
const traceSessionId = options?.traceSessionId ?? agentIdOrSessionId
return async (input: RequestInfo | URL, init?: RequestInit) => {
const state = dumpState.get(agentIdOrSessionId) ?? {
@ -158,17 +215,212 @@ export function createDumpPromptsFetch(
dumpState.set(agentIdOrSessionId, state)
let timestamp: string | undefined
let traceStartedAtMs = 0
let traceRequestBody: unknown
if (init?.method === 'POST' && init.body) {
timestamp = new Date().toISOString()
traceStartedAtMs = Date.now()
traceRequestBody = init.body
// Parsing + stringifying the request (system prompt + tool schemas = MBs)
// takes hundreds of ms. Defer so it doesn't block the actual API call —
// this is debug tooling for /issue, not on the critical path.
setImmediate(dumpRequest, init.body as string, timestamp, state, filePath)
}
const requestUrl = getRequestUrl(input)
const isProviderProxyTrace = traceEnabled && isLocalProviderProxyUrl(requestUrl)
const traceProvider = getTraceProviderFromEnv()
const requestInit = isProviderProxyTrace
? {
...init,
headers: (() => {
const headers = new Headers(init?.headers)
if (!headers.has(TRACE_SESSION_HEADER)) {
headers.set(TRACE_SESSION_HEADER, traceSessionId)
}
return headers
})(),
}
: init
const traceModel = extractModelFromRequestBody(traceRequestBody)
const traceCallId = timestamp && traceEnabled && !isProviderProxyTrace
? createTraceCallId()
: undefined
if (timestamp && traceCallId) {
void traceCaptureService.recordCall({
id: traceCallId,
sessionId: traceSessionId,
source: 'anthropic',
querySource: options?.querySource,
provider: traceProvider,
model: traceModel,
status: 'pending',
startedAt: timestamp,
request: {
method: init?.method ?? 'POST',
url: requestUrl,
headers: requestInit?.headers as Headers | Record<string, string> | undefined,
bodySnapshot: createRequestPendingSnapshot(traceRequestBody),
},
metadata: {
phase: 'api_call_started',
},
})
void traceCaptureService.recordEvent({
sessionId: traceSessionId,
callId: traceCallId,
source: 'anthropic',
provider: traceProvider,
model: traceModel,
timestamp,
phase: 'api_call_started',
severity: 'info',
title: 'API call started',
metadata: {
url: requestUrl,
},
})
}
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
const response = await globalThis.fetch(input, init)
let response: Response
try {
response = await globalThis.fetch(input, requestInit)
} catch (err) {
if (timestamp && traceCallId) {
const completedAt = new Date().toISOString()
void traceCaptureService.recordCall({
id: traceCallId,
sessionId: traceSessionId,
source: 'anthropic',
querySource: options?.querySource,
provider: traceProvider,
model: traceModel,
status: 'error',
startedAt: timestamp,
completedAt,
durationMs: Date.now() - traceStartedAtMs,
request: {
method: init?.method ?? 'POST',
url: requestUrl,
headers: requestInit?.headers as Headers | Record<string, string> | undefined,
bodySnapshot: createRequestPendingSnapshot(traceRequestBody),
},
error: err,
metadata: {
phase: 'api_call_failed',
},
})
void traceCaptureService.recordEvent({
sessionId: traceSessionId,
callId: traceCallId,
source: 'anthropic',
provider: traceProvider,
model: traceModel,
timestamp: completedAt,
phase: 'api_call_failed',
severity: 'error',
title: 'API call failed',
message: err instanceof Error ? err.message : String(err),
metadata: {
url: requestUrl,
},
})
}
throw err
}
if (timestamp && traceCallId) {
const cloned = response.clone()
void (async () => {
try {
const bodySnapshot = await readResponseTraceSnapshot(cloned)
await traceCaptureService.recordCall({
id: traceCallId,
sessionId: traceSessionId,
source: 'anthropic',
querySource: options?.querySource,
provider: traceProvider,
model: traceModel,
startedAt: timestamp,
completedAt: new Date().toISOString(),
durationMs: Date.now() - traceStartedAtMs,
request: {
method: init?.method ?? 'POST',
url: requestUrl,
headers: requestInit?.headers as Headers | Record<string, string> | undefined,
body: traceRequestBody,
},
response: {
status: response.status,
headers: response.headers,
bodySnapshot,
},
metadata: {
phase: 'api_call_completed',
},
})
await traceCaptureService.recordEvent({
sessionId: traceSessionId,
callId: traceCallId,
source: 'anthropic',
provider: traceProvider,
model: traceModel,
phase: 'api_call_completed',
severity: response.ok ? 'info' : 'warning',
title: 'API call completed',
metadata: {
status: response.status,
url: requestUrl,
},
})
} catch (err) {
await traceCaptureService.recordEvent({
sessionId: traceSessionId,
callId: traceCallId,
source: 'anthropic',
provider: traceProvider,
model: traceModel,
phase: 'response_capture_failed',
severity: 'warning',
title: 'Response capture failed',
message: err instanceof Error ? err.message : String(err),
metadata: {
status: response.status,
url: requestUrl,
},
})
await traceCaptureService.recordCall({
id: traceCallId,
sessionId: traceSessionId,
source: 'anthropic',
querySource: options?.querySource,
provider: traceProvider,
model: traceModel,
startedAt: timestamp,
completedAt: new Date().toISOString(),
durationMs: Date.now() - traceStartedAtMs,
request: {
method: init?.method ?? 'POST',
url: requestUrl,
headers: requestInit?.headers as Headers | Record<string, string> | undefined,
body: traceRequestBody,
},
response: {
status: response.status,
headers: response.headers,
},
metadata: {
phase: 'api_call_completed',
responseCaptureFailed: true,
},
})
}
})()
}
// Save response async
if (timestamp && response.ok && process.env.USER_TYPE === 'ant') {
@ -224,3 +476,15 @@ export function createDumpPromptsFetch(
return response
}
}
function extractModelFromRequestBody(body: unknown): string | undefined {
try {
const parsed = typeof body === 'string' ? jsonParse(body) : body
if (parsed && typeof parsed === 'object' && 'model' in parsed && typeof parsed.model === 'string') {
return parsed.model
}
} catch {
// Best effort only.
}
return undefined
}

View File

@ -0,0 +1,775 @@
import { createHash, randomUUID } from 'crypto'
import { existsSync, readFileSync, statSync } from 'fs'
import { promises as fs } from 'fs'
import { dirname, join } from 'path'
import { getClaudeConfigHomeDir, isEnvDefinedFalsy, isEnvTruthy } from '../../utils/envUtils.js'
const TRACE_PREVIEW_CHARS = 2048
const TRACE_STREAM_CAPTURE_BYTES = 256 * 1024
const TRACE_SETTINGS_KEY = 'traceCapture'
const SENSITIVE_KEY_RE = /authorization|api[-_]?key|secret|token|cookie|password|bearer/i
export type TraceCaptureSettings = {
enabled: boolean
storageDir: string
}
export type TraceProviderInfo = {
id: string | null
name: string
format: string
}
export type TraceBodySnapshot = {
contentType: 'json' | 'text' | 'empty'
bytes: number
sha256: string
preview: string
truncated: boolean
}
export type TraceCallStatus = 'pending' | 'ok' | 'error'
export type TraceEventSeverity = 'info' | 'warning' | 'error'
export type TraceCallRecord = {
id: string
sessionId: string
source: 'anthropic' | 'proxy'
querySource?: string
provider?: TraceProviderInfo
model?: string
status?: TraceCallStatus
startedAt: string
completedAt?: string
durationMs?: number
metadata?: Record<string, unknown>
request: {
method: string
url: string
headers: Record<string, string>
body: TraceBodySnapshot
}
response?: {
status: number
headers: Record<string, string>
body: TraceBodySnapshot
}
error?: {
name: string
message: string
code?: string
stack?: string
cause?: string
}
}
export type TraceEventRecord = {
id: string
sessionId: string
timestamp: string
phase: string
severity: TraceEventSeverity
callId?: string
source?: TraceCallRecord['source']
provider?: TraceProviderInfo
model?: string
title?: string
message?: string
metadata?: Record<string, unknown>
}
export type TraceSessionSummary = {
apiCalls: number
failedCalls: number
totalDurationMs: number
totalInputTokens: number
totalOutputTokens: number
models: Array<{ model: string; calls: number }>
updatedAt: string | null
}
export type TraceSession = {
sessionId: string
summary: TraceSessionSummary
calls: TraceCallRecord[]
events: TraceEventRecord[]
}
export type TraceSessionListItem = {
sessionId: string
summary: TraceSessionSummary
fileSize: number
fileUpdatedAt: string
}
export type TraceSessionFileItem = {
sessionId: string
fileSize: number
fileUpdatedAt: string
}
export type TraceSessionFileList = {
files: TraceSessionFileItem[]
total: number
storageDir: string
settings: TraceCaptureSettings
}
export type TraceSessionList = {
traces: TraceSessionListItem[]
total: number
storageDir: string
settings: TraceCaptureSettings
}
export type RecordTraceCallInput = {
id?: string
sessionId: string
source: TraceCallRecord['source']
querySource?: string
provider?: TraceProviderInfo
model?: string
status?: TraceCallStatus
startedAt?: string
completedAt?: string
durationMs?: number
metadata?: Record<string, unknown>
request: {
method?: string
url?: string
headers?: Headers | Record<string, string> | null
body?: unknown
bodySnapshot?: TraceBodySnapshot
}
response?: {
status: number
headers?: Headers | Record<string, string> | null
body?: unknown
bodySnapshot?: TraceBodySnapshot
}
error?: unknown
}
export type RecordTraceEventInput = {
id?: string
sessionId: string
timestamp?: string
phase: string
severity?: TraceEventSeverity
callId?: string
source?: TraceCallRecord['source']
provider?: TraceProviderInfo
model?: string
title?: string
message?: string
metadata?: Record<string, unknown>
}
type TraceFileEntry =
| TraceCallRecord
| { type: 'call'; record: TraceCallRecord }
| { type: 'event'; event: TraceEventRecord }
const traceWriteQueues = new Map<string, Promise<void>>()
export function shouldCaptureApiTrace(): boolean {
if (isEnvDefinedFalsy(process.env.CC_HAHA_TRACE_API_CALLS)) return false
if (isEnvTruthy(process.env.CC_HAHA_TRACE_API_CALLS)) return true
return readTraceCaptureSettingsSync().enabled &&
process.env.CLAUDE_CODE_ENTRYPOINT === 'claude-desktop'
}
export function isTraceCaptureEnabled(): boolean {
if (isEnvDefinedFalsy(process.env.CC_HAHA_TRACE_API_CALLS)) return false
if (isEnvTruthy(process.env.CC_HAHA_TRACE_API_CALLS)) return true
return readTraceCaptureSettingsSync().enabled
}
export function getTraceStorageDir(): string {
return join(getClaudeConfigHomeDir(), 'cc-haha', 'traces')
}
export function readTraceCaptureSettingsSync(): TraceCaptureSettings {
const settings = readManagedSettingsSync()
return normalizeTraceCaptureSettings(settings)
}
export async function readTraceCaptureSettings(): Promise<TraceCaptureSettings> {
const settings = await readManagedSettings()
return normalizeTraceCaptureSettings(settings)
}
export async function updateTraceCaptureSettings(input: Partial<Pick<TraceCaptureSettings, 'enabled'>>): Promise<TraceCaptureSettings> {
const current = await readManagedSettings()
const traceCapture = current[TRACE_SETTINGS_KEY]
const previous = traceCapture && typeof traceCapture === 'object' && !Array.isArray(traceCapture)
? traceCapture as Record<string, unknown>
: {}
const nextTraceCapture = {
...previous,
...(typeof input.enabled === 'boolean' ? { enabled: input.enabled } : {}),
}
const nextSettings = {
...current,
[TRACE_SETTINGS_KEY]: nextTraceCapture,
}
await writeManagedSettings(nextSettings)
return normalizeTraceCaptureSettings(nextSettings)
}
export function createTraceBodySnapshot(
body: unknown,
options?: { maxPreviewChars?: number; alreadyTruncated?: boolean },
): TraceBodySnapshot {
const maxPreviewChars = options?.maxPreviewChars ?? TRACE_PREVIEW_CHARS
const { serialized, contentType } = serializeTraceBody(body)
const bytes = Buffer.byteLength(serialized)
const preview = serialized.length > maxPreviewChars
? serialized.slice(0, maxPreviewChars)
: serialized
return {
contentType,
bytes,
sha256: createHash('sha256').update(serialized).digest('hex'),
preview,
truncated: Boolean(options?.alreadyTruncated) || serialized.length > maxPreviewChars,
}
}
export function clearTraceCaptureStateForTests(): void {
traceWriteQueues.clear()
}
export function createTraceCallId(): string {
return randomUUID()
}
class TraceCaptureService {
async recordCall(input: RecordTraceCallInput): Promise<TraceCallRecord | null> {
if (!input.sessionId.trim()) return null
if (!isTraceCaptureEnabled()) return null
const startedAt = input.startedAt ?? new Date().toISOString()
const completedAt = input.completedAt
const record: TraceCallRecord = {
id: input.id ?? createTraceCallId(),
sessionId: input.sessionId,
source: input.source,
...(input.querySource ? { querySource: input.querySource } : {}),
...(input.provider ? { provider: input.provider } : {}),
...(input.model ? { model: input.model } : {}),
status: input.status ?? inferCallStatus(input),
startedAt,
...(completedAt ? { completedAt } : {}),
...(typeof input.durationMs === 'number' ? { durationMs: input.durationMs } : {}),
...(input.metadata ? { metadata: sanitizeMetadata(input.metadata) } : {}),
request: {
method: input.request.method ?? 'POST',
url: sanitizeUrl(input.request.url ?? ''),
headers: sanitizeHeaders(input.request.headers),
body: input.request.bodySnapshot ?? createTraceBodySnapshot(input.request.body ?? null),
},
...(input.response
? {
response: {
status: input.response.status,
headers: sanitizeHeaders(input.response.headers),
body: input.response.bodySnapshot ?? createTraceBodySnapshot(input.response.body ?? null),
},
}
: {}),
...(input.error ? { error: normalizeTraceError(input.error) } : {}),
}
await appendTraceEntry(record.sessionId, { type: 'call', record })
return record
}
async recordEvent(input: RecordTraceEventInput): Promise<TraceEventRecord | null> {
if (!input.sessionId.trim()) return null
if (!isTraceCaptureEnabled()) return null
const event: TraceEventRecord = {
id: input.id ?? randomUUID(),
sessionId: input.sessionId,
timestamp: input.timestamp ?? new Date().toISOString(),
phase: input.phase,
severity: input.severity ?? 'info',
...(input.callId ? { callId: input.callId } : {}),
...(input.source ? { source: input.source } : {}),
...(input.provider ? { provider: input.provider } : {}),
...(input.model ? { model: input.model } : {}),
...(input.title ? { title: input.title } : {}),
...(input.message ? { message: redactSecretsInText(input.message) } : {}),
...(input.metadata ? { metadata: sanitizeMetadata(input.metadata) } : {}),
}
await appendTraceEntry(event.sessionId, { type: 'event', event })
return event
}
async getSessionTrace(sessionId: string): Promise<TraceSession> {
const { calls, events } = await readTraceEntries(sessionId)
return {
sessionId,
summary: summarizeCalls(calls),
calls,
events,
}
}
async listSessionTraces(options?: {
limit?: number
offset?: number
query?: string
all?: boolean
sessionIds?: string[]
}): Promise<TraceSessionList> {
const storageDir = getTraceStorageDir()
const settings = await readTraceCaptureSettings()
const all = options?.all === true
const limit = all ? Number.POSITIVE_INFINITY : clampListLimit(options?.limit ?? 50)
const offset = all ? 0 : Math.max(0, options?.offset ?? 0)
const query = options?.query?.trim().toLowerCase() ?? ''
const sessionIdFilter = options?.sessionIds?.length
? new Set(options.sessionIds.map((sessionId) => sanitizeTraceFileName(sessionId)))
: null
const files = (await listTraceFiles(storageDir))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
const filteredFiles = sessionIdFilter
? files.filter((file) => sessionIdFilter.has(file.name.replace(/\.jsonl$/, '')))
: files
const matchingFiles = query
? filteredFiles.filter((file) => file.name.replace(/\.jsonl$/, '').toLowerCase().includes(query))
: filteredFiles
const pageFiles = all ? matchingFiles : matchingFiles.slice(offset, offset + limit)
const items: TraceSessionListItem[] = []
for (const file of pageFiles) {
const sessionId = file.name.replace(/\.jsonl$/, '')
const trace = await this.getSessionTrace(sessionId)
const updatedAt = trace.summary.updatedAt ?? file.updatedAt
items.push({
sessionId: trace.sessionId || sessionId,
summary: trace.summary.updatedAt
? trace.summary
: { ...trace.summary, updatedAt },
fileSize: file.size,
fileUpdatedAt: file.updatedAt,
})
}
items.sort((a, b) => {
const aTime = a.summary.updatedAt ?? a.fileUpdatedAt
const bTime = b.summary.updatedAt ?? b.fileUpdatedAt
return bTime.localeCompare(aTime)
})
return {
traces: items,
total: matchingFiles.length,
storageDir,
settings,
}
}
async listSessionTraceFiles(): Promise<TraceSessionFileList> {
const storageDir = getTraceStorageDir()
const settings = await readTraceCaptureSettings()
const files = (await listTraceFiles(storageDir))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
return {
files: files.map((file) => ({
sessionId: file.name.replace(/\.jsonl$/, ''),
fileSize: file.size,
fileUpdatedAt: file.updatedAt,
})),
total: files.length,
storageDir,
settings,
}
}
}
export const traceCaptureService = new TraceCaptureService()
export async function readResponseTraceSnapshot(response: Response): Promise<TraceBodySnapshot> {
const contentType = response.headers.get('content-type') ?? ''
if (!response.body) {
return createTraceBodySnapshot(null)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let text = ''
let bytes = 0
let truncated = false
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
bytes += value.byteLength
if (text.length < TRACE_STREAM_CAPTURE_BYTES) {
text += decoder.decode(value, { stream: true })
} else {
truncated = true
}
if (bytes > TRACE_STREAM_CAPTURE_BYTES) {
truncated = true
}
}
text += decoder.decode()
} finally {
reader.releaseLock()
}
return createTraceBodySnapshot(
contentType.includes('application/json') ? parseJsonOrText(text) : text,
{ alreadyTruncated: truncated },
)
}
function serializeTraceBody(body: unknown): { serialized: string; contentType: TraceBodySnapshot['contentType'] } {
if (body === null || body === undefined) {
return { serialized: '', contentType: 'empty' }
}
if (typeof body === 'string') {
const parsed = parseJsonOrText(body)
if (typeof parsed !== 'string') {
return {
serialized: JSON.stringify(redactSensitiveValue(parsed), null, 2),
contentType: 'json',
}
}
return { serialized: redactSecretsInText(body), contentType: 'text' }
}
try {
return {
serialized: JSON.stringify(redactSensitiveValue(body), null, 2),
contentType: 'json',
}
} catch {
return { serialized: redactSecretsInText(String(body)), contentType: 'text' }
}
}
function parseJsonOrText(text: string): unknown {
const trimmed = text.trim()
if (!trimmed) return text
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return text
try {
return JSON.parse(trimmed)
} catch {
return text
}
}
function redactSensitiveValue(value: unknown, key = ''): unknown {
if (SENSITIVE_KEY_RE.test(key)) return '[redacted]'
if (Array.isArray(value)) return value.map((entry) => redactSensitiveValue(entry))
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => [
entryKey,
redactSensitiveValue(entryValue, entryKey),
]),
)
}
if (typeof value === 'string') return redactSecretsInText(value)
return value
}
function redactSecretsInText(value: string): string {
return value
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]')
.replace(/\bsk-[A-Za-z0-9._-]{8,}\b/g, 'sk-[redacted]')
}
function sanitizeHeaders(headers: Headers | Record<string, string> | null | undefined): Record<string, string> {
if (!headers) return {}
const entries = headers instanceof Headers
? Array.from(headers.entries())
: Object.entries(headers)
return Object.fromEntries(
entries.map(([key, value]) => [
key,
SENSITIVE_KEY_RE.test(key) ? '[redacted]' : redactSecretsInText(String(value)),
]),
)
}
function sanitizeUrl(url: string): string {
if (!url) return ''
try {
const parsed = new URL(url)
for (const key of Array.from(parsed.searchParams.keys())) {
if (SENSITIVE_KEY_RE.test(key)) {
parsed.searchParams.set(key, '[redacted]')
}
}
return parsed.toString()
} catch {
return url
}
}
function sanitizeMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
return redactSensitiveValue(metadata) as Record<string, unknown>
}
function inferCallStatus(input: RecordTraceCallInput): TraceCallStatus {
if (input.status) return input.status
if (input.error) return 'error'
if (!input.response && !input.completedAt) return 'pending'
if ((input.response?.status ?? 200) >= 400) return 'error'
return 'ok'
}
function normalizeTraceError(error: unknown): TraceCallRecord['error'] {
if (error instanceof Error) {
const code = typeof (error as NodeJS.ErrnoException).code === 'string'
? (error as NodeJS.ErrnoException).code
: undefined
const cause = 'cause' in error && error.cause !== undefined
? redactSecretsInText(String(error.cause))
: undefined
return {
name: error.name,
message: redactSecretsInText(error.message),
...(code ? { code } : {}),
...(error.stack ? { stack: redactSecretsInText(error.stack) } : {}),
...(cause ? { cause } : {}),
}
}
return { name: typeof error, message: redactSecretsInText(String(error)) }
}
async function appendTraceEntry(sessionId: string, entry: TraceFileEntry): Promise<void> {
const previous = traceWriteQueues.get(sessionId) ?? Promise.resolve()
const next = previous
.catch(() => {})
.then(async () => {
const filePath = getTraceFilePath(sessionId)
await fs.mkdir(dirname(filePath), { recursive: true })
await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, 'utf-8')
})
traceWriteQueues.set(sessionId, next)
try {
await next
} finally {
if (traceWriteQueues.get(sessionId) === next) {
traceWriteQueues.delete(sessionId)
}
}
}
async function listTraceFiles(storageDir: string): Promise<Array<{ name: string; size: number; updatedAt: string }>> {
let entries: string[] = []
try {
entries = await fs.readdir(storageDir)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
throw error
}
const files = await Promise.all(entries
.filter((name) => name.endsWith('.jsonl'))
.map(async (name) => {
const stat = await fs.stat(join(storageDir, name)).catch(() => null)
if (!stat?.isFile()) return null
return {
name,
size: stat.size,
updatedAt: stat.mtime.toISOString(),
}
}))
return files.filter((file): file is { name: string; size: number; updatedAt: string } => file !== null)
}
async function readTraceEntries(sessionId: string): Promise<{ calls: TraceCallRecord[]; events: TraceEventRecord[] }> {
const filePath = getTraceFilePath(sessionId)
let raw = ''
try {
raw = await fs.readFile(filePath, 'utf-8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { calls: [], events: [] }
throw error
}
const callsById = new Map<string, TraceCallRecord>()
const events: TraceEventRecord[] = []
for (const entry of raw
.split('\n')
.filter((line) => line.trim().length > 0)
.flatMap((line) => {
try {
return [JSON.parse(line) as TraceFileEntry]
} catch {
return []
}
})) {
if (!entry || typeof entry !== 'object') continue
if ('type' in entry && entry.type === 'event') {
if (isTraceEventRecordLike(entry.event)) {
events.push(entry.event)
}
continue
}
const call = 'type' in entry && entry.type === 'call' ? entry.record : entry
if (isTraceCallRecordLike(call)) {
callsById.set(call.id, call)
}
}
return {
calls: Array.from(callsById.values()).sort((a, b) => a.startedAt.localeCompare(b.startedAt)),
events: events.sort((a, b) => a.timestamp.localeCompare(b.timestamp)),
}
}
function isTraceCallRecordLike(value: unknown): value is TraceCallRecord {
if (!value || typeof value !== 'object') return false
const record = value as Partial<TraceCallRecord>
return typeof record.id === 'string'
&& typeof record.sessionId === 'string'
&& typeof record.source === 'string'
&& typeof record.startedAt === 'string'
&& Boolean(record.request)
}
function isTraceEventRecordLike(value: unknown): value is TraceEventRecord {
if (!value || typeof value !== 'object') return false
const record = value as Partial<TraceEventRecord>
return typeof record.id === 'string'
&& typeof record.sessionId === 'string'
&& typeof record.timestamp === 'string'
&& typeof record.phase === 'string'
&& typeof record.severity === 'string'
}
function summarizeCalls(calls: TraceCallRecord[]): TraceSessionSummary {
const modelCounts = new Map<string, number>()
let failedCalls = 0
let totalDurationMs = 0
let totalInputTokens = 0
let totalOutputTokens = 0
let updatedAt: string | null = null
for (const call of calls) {
if (call.status === 'error' || call.error || (call.response?.status ?? 200) >= 400) failedCalls += 1
if (typeof call.durationMs === 'number') totalDurationMs += call.durationMs
if (call.model) modelCounts.set(call.model, (modelCounts.get(call.model) ?? 0) + 1)
const usage = extractUsage(call.response?.body.preview)
totalInputTokens += usage.input
totalOutputTokens += usage.output
updatedAt = call.completedAt ?? call.startedAt
}
return {
apiCalls: calls.length,
failedCalls,
totalDurationMs,
totalInputTokens,
totalOutputTokens,
models: Array.from(modelCounts.entries()).map(([model, count]) => ({ model, calls: count })),
updatedAt,
}
}
function extractUsage(preview: string | undefined): { input: number; output: number } {
if (!preview) return { input: 0, output: 0 }
const parsed = parseJsonOrText(preview)
if (!parsed || typeof parsed !== 'object') return { input: 0, output: 0 }
const usage = 'usage' in parsed && parsed.usage && typeof parsed.usage === 'object'
? parsed.usage as Record<string, unknown>
: parsed as Record<string, unknown>
const input = numberFromUnknown(usage.input_tokens) + numberFromUnknown(usage.prompt_tokens)
const output = numberFromUnknown(usage.output_tokens) + numberFromUnknown(usage.completion_tokens)
return { input, output }
}
function numberFromUnknown(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
function getTraceFilePath(sessionId: string): string {
return join(getTraceStorageDir(), `${sanitizeTraceFileName(sessionId)}.jsonl`)
}
function sanitizeTraceFileName(sessionId: string): string {
return sessionId.replace(/[^a-zA-Z0-9._-]/g, '_')
}
function getManagedSettingsPath(): string {
return join(getClaudeConfigHomeDir(), 'cc-haha', 'settings.json')
}
function defaultTraceCaptureSettings(): TraceCaptureSettings {
return {
enabled: true,
storageDir: getTraceStorageDir(),
}
}
function normalizeTraceCaptureSettings(settings: Record<string, unknown>): TraceCaptureSettings {
const defaultSettings = defaultTraceCaptureSettings()
const traceCapture = settings[TRACE_SETTINGS_KEY]
if (!traceCapture || typeof traceCapture !== 'object' || Array.isArray(traceCapture)) {
return defaultSettings
}
return {
...defaultSettings,
enabled: (traceCapture as Record<string, unknown>).enabled !== false,
}
}
function readManagedSettingsSync(): Record<string, unknown> {
const filePath = getManagedSettingsPath()
try {
if (!existsSync(filePath)) return {}
const stat = statSync(filePath)
if (!stat.isFile()) return {}
const parsed = JSON.parse(readFileSync(filePath, 'utf-8')) as unknown
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {}
} catch {
return {}
}
}
async function readManagedSettings(): Promise<Record<string, unknown>> {
try {
const parsed = JSON.parse(await fs.readFile(getManagedSettingsPath(), 'utf-8')) as unknown
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}
return {}
}
}
async function writeManagedSettings(settings: Record<string, unknown>): Promise<void> {
const filePath = getManagedSettingsPath()
const tmpFile = `${filePath}.tmp.${process.pid}.${Date.now()}.${randomUUID()}`
await fs.mkdir(dirname(filePath), { recursive: true })
await fs.writeFile(tmpFile, `${JSON.stringify(settings, null, 2)}\n`, 'utf-8')
await fs.rename(tmpFile, filePath)
}
function clampListLimit(limit: number): number {
if (!Number.isFinite(limit) || limit <= 0) return 50
return Math.min(Math.max(Math.round(limit), 1), 200)
}