Merge commit '071975e0cc007f88e2424dc86d3310992cf1eb41'

This commit is contained in:
程序员阿江(Relakkes) 2026-05-02 12:42:19 +08:00
commit 0b87fd0f7e
18 changed files with 1535 additions and 26 deletions

View File

@ -0,0 +1,185 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Settings } from '../pages/Settings'
import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
const diagnosticsApiMock = vi.hoisted(() => ({
getStatus: vi.fn(),
getEvents: vi.fn(),
exportBundle: vi.fn(),
openLogDir: vi.fn(),
clear: vi.fn(),
}))
vi.mock('../api/diagnostics', () => ({
diagnosticsApi: diagnosticsApiMock,
}))
vi.mock('../stores/providerStore', () => ({
useProviderStore: () => ({
providers: [],
activeId: null,
hasLoadedProviders: true,
presets: [],
isLoading: false,
isPresetsLoading: false,
fetchProviders: vi.fn(),
fetchPresets: vi.fn(),
deleteProvider: vi.fn(),
activateProvider: vi.fn(),
activateOfficial: vi.fn(),
testProvider: vi.fn(),
createProvider: vi.fn(),
updateProvider: vi.fn(),
testConfig: vi.fn(),
}),
}))
vi.mock('../api/providers', () => ({
providersApi: {
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn().mockResolvedValue({}),
},
}))
vi.mock('../components/settings/ClaudeOfficialLogin', () => ({
ClaudeOfficialLogin: () => <div />,
}))
vi.mock('../pages/AdapterSettings', () => ({
AdapterSettings: () => <div />,
}))
vi.mock('../stores/agentStore', () => ({
useAgentStore: () => ({
activeAgents: [],
allAgents: [],
isLoading: false,
error: null,
selectedAgent: null,
fetchAgents: vi.fn(),
selectAgent: vi.fn(),
}),
}))
vi.mock('../stores/skillStore', () => ({
useSkillStore: () => ({
skills: [],
selectedSkill: null,
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills: vi.fn(),
fetchSkillDetail: vi.fn(),
clearSelection: vi.fn(),
}),
}))
vi.mock('../components/chat/CodeViewer', () => ({
CodeViewer: ({ code }: { code: string }) => <pre>{code}</pre>,
}))
describe('Settings > Diagnostics tab', () => {
beforeEach(() => {
diagnosticsApiMock.getStatus.mockResolvedValue({
logDir: '/tmp/claude/cc-haha/diagnostics',
diagnosticsPath: '/tmp/claude/cc-haha/diagnostics/diagnostics.jsonl',
runtimeErrorsPath: '/tmp/claude/cc-haha/diagnostics/runtime-errors.log',
exportDir: '/tmp/claude/cc-haha/diagnostics/exports',
retentionDays: 7,
maxBytes: 50 * 1024 * 1024,
totalBytes: 4096,
eventCount: 2,
recentErrorCount: 1,
lastEventAt: '2026-05-02T00:00:00.000Z',
})
diagnosticsApiMock.getEvents.mockResolvedValue({
events: [{
id: 'event-1',
timestamp: '2026-05-02T00:00:00.000Z',
type: 'cli_start_failed',
severity: 'error',
summary: 'CLI exited during startup with code 1',
sessionId: 'session-1',
}],
})
diagnosticsApiMock.exportBundle.mockResolvedValue({
bundle: {
path: '/tmp/claude/cc-haha/diagnostics/exports/cc-haha-diagnostics.tar.gz',
fileName: 'cc-haha-diagnostics.tar.gz',
bytes: 1024,
},
})
diagnosticsApiMock.openLogDir.mockResolvedValue({ ok: true })
diagnosticsApiMock.clear.mockResolvedValue({ ok: true })
useSettingsStore.setState({ locale: 'en' })
useUIStore.setState({ pendingSettingsTab: null, toasts: [] })
})
it('shows diagnostics status, actions, and recent events', async () => {
render(<Settings />)
fireEvent.click(screen.getByText('Diagnostics'))
expect(await screen.findByText('Log directory')).toBeInTheDocument()
expect(screen.getByText('/tmp/claude/cc-haha/diagnostics')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Export Bundle/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Copy Error Summary/i })).toBeInTheDocument()
expect(screen.getByText('cli_start_failed')).toBeInTheDocument()
expect(screen.getByText('CLI exited during startup with code 1')).toBeInTheDocument()
})
it('exports a diagnostics bundle from the settings page', async () => {
render(<Settings />)
fireEvent.click(screen.getByText('Diagnostics'))
fireEvent.click(await screen.findByRole('button', { name: /Export Bundle/i }))
await waitFor(() => {
expect(diagnosticsApiMock.exportBundle).toHaveBeenCalled()
})
expect(await screen.findByText('/tmp/claude/cc-haha/diagnostics/exports/cc-haha-diagnostics.tar.gz')).toBeInTheDocument()
})
it('copies the recent error summary with the legacy clipboard fallback', async () => {
const originalClipboard = navigator.clipboard
const originalExecCommand = document.execCommand
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: vi.fn().mockReturnValue(true),
})
const execCommand = vi.mocked(document.execCommand)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')),
},
})
try {
render(<Settings />)
fireEvent.click(screen.getByText('Diagnostics'))
fireEvent.click(await screen.findByRole('button', { name: /Copy Error Summary/i }))
await waitFor(() => {
expect(execCommand).toHaveBeenCalledWith('copy')
})
const toasts = useUIStore.getState().toasts
expect(toasts[toasts.length - 1]?.message).toBe('Error summary copied.')
} finally {
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: originalExecCommand,
})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: originalClipboard,
})
}
})
})

View File

@ -0,0 +1,40 @@
import { api } from './client'
export type DiagnosticSeverity = 'debug' | 'info' | 'warn' | 'error'
export type DiagnosticEvent = {
id: string
timestamp: string
type: string
severity: DiagnosticSeverity
summary: string
sessionId?: string
details?: unknown
}
export type DiagnosticsStatus = {
logDir: string
diagnosticsPath: string
runtimeErrorsPath: string
exportDir: string
retentionDays: number
maxBytes: number
totalBytes: number
eventCount: number
recentErrorCount: number
lastEventAt: string | null
}
export type DiagnosticsBundle = {
path: string
fileName: string
bytes: number
}
export const diagnosticsApi = {
getStatus: () => api.get<DiagnosticsStatus>('/api/diagnostics/status'),
getEvents: (limit = 100) => api.get<{ events: DiagnosticEvent[] }>(`/api/diagnostics/events?limit=${limit}`),
exportBundle: () => api.post<{ bundle: DiagnosticsBundle }>('/api/diagnostics/export', undefined, { timeout: 60_000 }),
openLogDir: () => api.post<{ ok: true }>('/api/diagnostics/open-log-dir'),
clear: () => api.delete<{ ok: true }>('/api/diagnostics'),
}

View File

@ -94,6 +94,7 @@ export const en = {
'settings.tab.skills': 'Skills',
'settings.tab.mcp': 'MCP',
'settings.tab.plugins': 'Plugins',
'settings.tab.diagnostics': 'Diagnostics',
// Settings > Terminal
'settings.terminal.title': 'Terminal',
@ -111,6 +112,34 @@ export const en = {
'settings.terminal.status.unavailable': 'Unavailable',
'terminal.newTab': 'New Terminal',
// Settings > Diagnostics
'settings.diagnostics.title': 'Diagnostics',
'settings.diagnostics.description': 'Server and CLI runtime logs for debugging startup, provider, and session failures.',
'settings.diagnostics.refresh': 'Refresh',
'settings.diagnostics.totalSize': 'Log size',
'settings.diagnostics.events': 'Events',
'settings.diagnostics.recentErrors': '24h warnings',
'settings.diagnostics.retention': 'Retention',
'settings.diagnostics.retentionValue': '{days} days / {size}',
'settings.diagnostics.logDirectory': 'Log directory',
'settings.diagnostics.openDirectory': 'Open',
'settings.diagnostics.exportBundle': 'Export Bundle',
'settings.diagnostics.copySummary': 'Copy Error Summary',
'settings.diagnostics.clearLogs': 'Clear Logs',
'settings.diagnostics.recentEvents': 'Recent Events',
'settings.diagnostics.privacyNote': 'Exported diagnostics are sanitized and do not include chat content, file contents, full environment variables, or API keys.',
'settings.diagnostics.noEvents': 'No diagnostic events yet.',
'settings.diagnostics.noRecentErrors': 'No recent warnings or errors.',
'settings.diagnostics.loadFailed': 'Failed to load diagnostics.',
'settings.diagnostics.openFailed': 'Failed to open diagnostics directory.',
'settings.diagnostics.exportFailed': 'Failed to export diagnostics bundle.',
'settings.diagnostics.exported': 'Exported {file}',
'settings.diagnostics.summaryCopied': 'Error summary copied.',
'settings.diagnostics.copyFailed': 'Failed to copy error summary.',
'settings.diagnostics.confirmClear': 'Clear all local diagnostic logs and exported bundles?',
'settings.diagnostics.cleared': 'Diagnostics cleared.',
'settings.diagnostics.clearFailed': 'Failed to clear diagnostics.',
// Settings > Claude Official Login
'settings.claudeOfficialLogin.intro': 'Using official Claude models requires signing in to your Claude.ai account. Click the button below to open the official Claude login page in your browser; you\'ll be returned here after authorizing.',
'settings.claudeOfficialLogin.loginButton': 'Sign in to Claude',

View File

@ -96,6 +96,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.tab.skills': '技能',
'settings.tab.mcp': 'MCP',
'settings.tab.plugins': '插件',
'settings.tab.diagnostics': '诊断',
// Settings > Terminal
'settings.terminal.title': '终端',
@ -113,6 +114,34 @@ export const zh: Record<TranslationKey, string> = {
'settings.terminal.status.unavailable': '不可用',
'terminal.newTab': '新建终端',
// Settings > Diagnostics
'settings.diagnostics.title': '诊断',
'settings.diagnostics.description': '记录服务端与 CLI 的启动、服务商、会话运行错误,便于复现和定位问题。',
'settings.diagnostics.refresh': '刷新',
'settings.diagnostics.totalSize': '日志大小',
'settings.diagnostics.events': '事件数',
'settings.diagnostics.recentErrors': '24h 警告',
'settings.diagnostics.retention': '保留策略',
'settings.diagnostics.retentionValue': '{days} 天 / {size}',
'settings.diagnostics.logDirectory': '日志目录',
'settings.diagnostics.openDirectory': '打开',
'settings.diagnostics.exportBundle': '导出诊断包',
'settings.diagnostics.copySummary': '复制错误摘要',
'settings.diagnostics.clearLogs': '清理日志',
'settings.diagnostics.recentEvents': '最近事件',
'settings.diagnostics.privacyNote': '导出的诊断包会脱敏,不包含聊天内容、文件内容、完整环境变量或 API Key。',
'settings.diagnostics.noEvents': '暂无诊断事件。',
'settings.diagnostics.noRecentErrors': '最近没有警告或错误。',
'settings.diagnostics.loadFailed': '加载诊断信息失败。',
'settings.diagnostics.openFailed': '打开诊断目录失败。',
'settings.diagnostics.exportFailed': '导出诊断包失败。',
'settings.diagnostics.exported': '已导出 {file}',
'settings.diagnostics.summaryCopied': '错误摘要已复制。',
'settings.diagnostics.copyFailed': '复制错误摘要失败。',
'settings.diagnostics.confirmClear': '确定清理所有本地诊断日志和已导出的诊断包?',
'settings.diagnostics.cleared': '诊断日志已清理。',
'settings.diagnostics.clearFailed': '清理诊断日志失败。',
// Settings > Claude Official Login
'settings.claudeOfficialLogin.intro': '使用官方 Claude 模型需要登录你的 Claude.ai 账号。点击下方按钮,浏览器会打开 Claude 官方登录页面,授权后自动回到这里。',
'settings.claudeOfficialLogin.loginButton': '登录 Claude 账号',

View File

@ -0,0 +1,218 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { diagnosticsApi, type DiagnosticEvent, type DiagnosticsStatus } from '../api/diagnostics'
import { Button } from '../components/shared/Button'
import { copyTextToClipboard } from '../components/chat/clipboard'
import { useTranslation } from '../i18n'
import { formatBytes } from '../lib/formatBytes'
import { useUIStore } from '../stores/uiStore'
export function DiagnosticsSettings() {
const t = useTranslation()
const addToast = useUIStore((s) => s.addToast)
const [status, setStatus] = useState<DiagnosticsStatus | null>(null)
const [events, setEvents] = useState<DiagnosticEvent[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isExporting, setIsExporting] = useState(false)
const [isClearing, setIsClearing] = useState(false)
const [lastExportPath, setLastExportPath] = useState<string | null>(null)
const load = useCallback(async () => {
setIsLoading(true)
try {
const [nextStatus, eventResult] = await Promise.all([
diagnosticsApi.getStatus(),
diagnosticsApi.getEvents(100),
])
setStatus(nextStatus)
setEvents(eventResult.events)
} catch (error) {
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('settings.diagnostics.loadFailed'),
})
} finally {
setIsLoading(false)
}
}, [addToast, t])
useEffect(() => {
void load()
}, [load])
const recentErrorSummary = useMemo(() => {
return events
.filter((event) => event.severity === 'error' || event.severity === 'warn')
.slice(0, 20)
.map((event) => `[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}: ${event.summary}`)
.join('\n')
}, [events])
const handleOpenDir = async () => {
try {
await diagnosticsApi.openLogDir()
} catch (error) {
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('settings.diagnostics.openFailed'),
})
}
}
const handleExport = async () => {
setIsExporting(true)
try {
const { bundle } = await diagnosticsApi.exportBundle()
setLastExportPath(bundle.path)
addToast({
type: 'success',
message: t('settings.diagnostics.exported', { file: bundle.fileName }),
})
await load()
} catch (error) {
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('settings.diagnostics.exportFailed'),
})
} finally {
setIsExporting(false)
}
}
const handleCopySummary = async () => {
const text = recentErrorSummary || t('settings.diagnostics.noRecentErrors')
const copied = await copyTextToClipboard(text)
if (copied) {
addToast({ type: 'success', message: t('settings.diagnostics.summaryCopied') })
return
}
addToast({ type: 'error', message: t('settings.diagnostics.copyFailed') })
}
const handleClear = async () => {
if (!window.confirm(t('settings.diagnostics.confirmClear'))) return
setIsClearing(true)
try {
await diagnosticsApi.clear()
setEvents([])
setStatus(await diagnosticsApi.getStatus())
setLastExportPath(null)
addToast({ type: 'success', message: t('settings.diagnostics.cleared') })
} catch (error) {
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('settings.diagnostics.clearFailed'),
})
} finally {
setIsClearing(false)
}
}
return (
<div className="max-w-4xl">
<div className="flex items-start justify-between gap-4 mb-5">
<div>
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">{t('settings.diagnostics.title')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mt-0.5">{t('settings.diagnostics.description')}</p>
</div>
<Button variant="secondary" size="sm" onClick={load} loading={isLoading}>
<span className="material-symbols-outlined text-[16px]">refresh</span>
{t('settings.diagnostics.refresh')}
</Button>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
<Metric label={t('settings.diagnostics.totalSize')} value={status ? formatBytes(status.totalBytes) : '-'} />
<Metric label={t('settings.diagnostics.events')} value={status ? String(status.eventCount) : '-'} />
<Metric label={t('settings.diagnostics.recentErrors')} value={status ? String(status.recentErrorCount) : '-'} />
<Metric label={t('settings.diagnostics.retention')} value={status ? t('settings.diagnostics.retentionValue', { days: String(status.retentionDays), size: formatBytes(status.maxBytes) }) : '-'} />
</div>
<div className="border border-[var(--color-border)] rounded-lg mb-5">
<div className="px-4 py-3 border-b border-[var(--color-border)] flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-[var(--color-text-primary)]">{t('settings.diagnostics.logDirectory')}</div>
<div className="text-xs text-[var(--color-text-tertiary)] font-mono break-all mt-0.5">{status?.logDir ?? '-'}</div>
</div>
<Button variant="secondary" size="sm" onClick={handleOpenDir}>
<span className="material-symbols-outlined text-[16px]">folder_open</span>
{t('settings.diagnostics.openDirectory')}
</Button>
</div>
<div className="px-4 py-3 flex flex-wrap items-center gap-2">
<Button size="sm" onClick={handleExport} loading={isExporting}>
<span className="material-symbols-outlined text-[16px]">archive</span>
{t('settings.diagnostics.exportBundle')}
</Button>
<Button variant="secondary" size="sm" onClick={handleCopySummary}>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
{t('settings.diagnostics.copySummary')}
</Button>
<Button variant="danger" size="sm" onClick={handleClear} loading={isClearing}>
<span className="material-symbols-outlined text-[16px]">delete</span>
{t('settings.diagnostics.clearLogs')}
</Button>
{lastExportPath && (
<span className="text-xs text-[var(--color-text-tertiary)] font-mono break-all">
{lastExportPath}
</span>
)}
</div>
</div>
<div className="mb-3">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.diagnostics.recentEvents')}</h3>
<p className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('settings.diagnostics.privacyNote')}</p>
</div>
<div className="border border-[var(--color-border)] rounded-lg overflow-hidden">
{events.length === 0 ? (
<div className="px-4 py-8 text-sm text-[var(--color-text-tertiary)] text-center">
{isLoading ? t('common.loading') : t('settings.diagnostics.noEvents')}
</div>
) : (
<div className="divide-y divide-[var(--color-border)]">
{events.map((event) => (
<EventRow key={event.id} event={event} />
))}
</div>
)}
</div>
</div>
)
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="border border-[var(--color-border)] rounded-lg px-3 py-2">
<div className="text-xs text-[var(--color-text-tertiary)]">{label}</div>
<div className="text-sm font-semibold text-[var(--color-text-primary)] mt-1">{value}</div>
</div>
)
}
function EventRow({ event }: { event: DiagnosticEvent }) {
const severityClass =
event.severity === 'error'
? 'text-[var(--color-error)]'
: event.severity === 'warn'
? 'text-[var(--color-warning)]'
: 'text-[var(--color-text-tertiary)]'
return (
<div className="px-4 py-3 grid grid-cols-[120px_92px_1fr] gap-3 items-start">
<div className="text-xs text-[var(--color-text-tertiary)] font-mono">
{new Date(event.timestamp).toLocaleString()}
</div>
<div className={`text-xs font-semibold uppercase ${severityClass}`}>{event.severity}</div>
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium text-[var(--color-text-primary)] truncate">{event.type}</span>
{event.sessionId && (
<span className="text-[11px] text-[var(--color-text-tertiary)] font-mono truncate">{event.sessionId}</span>
)}
</div>
<div className="text-xs text-[var(--color-text-secondary)] mt-1 break-words">{event.summary}</div>
</div>
</div>
)
}

View File

@ -25,6 +25,7 @@ import { PluginDetail } from '../components/plugins/PluginDetail'
import { ComputerUseSettings } from './ComputerUseSettings'
import { McpSettings } from './McpSettings'
import { TerminalSettings } from './TerminalSettings'
import { DiagnosticsSettings } from './DiagnosticsSettings'
import { useUIStore, type SettingsTab } from '../stores/uiStore'
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
import { useUpdateStore } from '../stores/updateStore'
@ -58,6 +59,7 @@ export function Settings() {
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
<TabButton icon="extension" label={t('settings.tab.plugins')} active={activeTab === 'plugins'} onClick={() => setActiveTab('plugins')} />
<TabButton icon="mouse" label={t('settings.tab.computerUse')} active={activeTab === 'computerUse'} onClick={() => setActiveTab('computerUse')} />
<TabButton icon="monitor_heart" label={t('settings.tab.diagnostics')} active={activeTab === 'diagnostics'} onClick={() => setActiveTab('diagnostics')} />
</div>
<div className="border-t border-[var(--color-border)]/40 pt-1">
<TabButton icon="info" label={t('settings.tab.about')} active={activeTab === 'about'} onClick={() => setActiveTab('about')} />
@ -76,6 +78,7 @@ export function Settings() {
{activeTab === 'skills' && <SkillSettings />}
{activeTab === 'plugins' && <PluginSettings />}
{activeTab === 'computerUse' && <ComputerUseSettings />}
{activeTab === 'diagnostics' && <DiagnosticsSettings />}
{activeTab === 'about' && <AboutSettings />}
</div>
</div>

View File

@ -39,6 +39,7 @@ export type SettingsTab =
| 'skills'
| 'plugins'
| 'computerUse'
| 'diagnostics'
| 'about'
type ActiveView = 'code' | 'scheduled' | 'terminal' | 'history' | 'settings'

View File

@ -185,7 +185,7 @@ describe('ConversationService', () => {
expect(() => svc.onOutput('no-such-session', () => {})).not.toThrow()
})
it('should ignore stale process exits after a session restarts', () => {
it('should ignore stale process exits after a session restarts', async () => {
const svc = new ConversationService()
const oldProc = { pid: 1 } as any
const newProc = { pid: 2 } as any
@ -203,10 +203,10 @@ describe('ConversationService', () => {
pendingPermissionRequests: new Map(),
})
;(svc as any).handleProcessExit('session-restart', oldProc, 143)
await (svc as any).handleProcessExit('session-restart', oldProc, 143)
expect(svc.hasSession('session-restart')).toBe(true)
;(svc as any).handleProcessExit('session-restart', newProc, 0)
await (svc as any).handleProcessExit('session-restart', newProc, 0)
expect(svc.hasSession('session-restart')).toBe(false)
})
@ -445,6 +445,34 @@ describe('WebSocket Chat Integration', () => {
}
}
async function withMockStartupStdoutExit<T>(
stdout: string,
exitDelayMs: number,
callback: () => Promise<T>,
): Promise<T> {
const previousStdout = process.env.MOCK_SDK_STARTUP_STDOUT
const previousExitDelay = process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS
process.env.MOCK_SDK_STARTUP_STDOUT = stdout
process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS = String(exitDelayMs)
try {
return await callback()
} finally {
if (previousStdout === undefined) {
delete process.env.MOCK_SDK_STARTUP_STDOUT
} else {
process.env.MOCK_SDK_STARTUP_STDOUT = previousStdout
}
if (previousExitDelay === undefined) {
delete process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS
} else {
process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS = previousExitDelay
}
}
}
async function runTurn(sessionId: string, content: string, allowError = false): Promise<any[]> {
const messages: any[] = []
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
@ -872,6 +900,46 @@ describe('WebSocket Chat Integration', () => {
expect(secondTurn.some((m) => m.type === 'error')).toBe(false)
})
it('should keep a long desktop session alive in a /tmp project across engineering turns', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-issue247-project-'))
try {
await fs.writeFile(
path.join(projectDir, 'package.json'),
JSON.stringify({ name: 'issue-247-repro', type: 'module' }, null, 2),
)
await fs.mkdir(path.join(projectDir, 'src'), { recursive: true })
await fs.writeFile(
path.join(projectDir, 'src', 'index.ts'),
'export function greet(name: string) { return `hello ${name}` }\n',
)
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir: projectDir }),
})
expect(createRes.status).toBe(201)
const { sessionId } = await createRes.json() as { sessionId: string }
const prompts = [
'Inspect this TypeScript project and summarize what you see.',
'Plan a small change to add a farewell helper.',
'Implement the helper in src/index.ts.',
'Review whether the exported functions are easy to test.',
'Suggest the next regression test for this project.',
]
for (const prompt of prompts) {
const messages = await runTurn(sessionId, prompt)
expect(messages.some((m) => m.type === 'error')).toBe(false)
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
}
} finally {
await fs.rm(projectDir, { recursive: true, force: true })
}
}, 20_000)
it('should clear a desktop session without sending /clear to the CLI turn loop', async () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
@ -951,6 +1019,25 @@ describe('WebSocket Chat Integration', () => {
expect(error?.message).toContain('configuredProviders:')
})
it('should include CLI stdout diagnostics when startup exits before SDK messages', async () => {
const sessionId = `chat-startup-stdout-${crypto.randomUUID()}`
const messages = await withMockStartupStdoutExit(
'provider rejected request: invalid model id',
25,
() => runTurn(sessionId, 'trigger startup stdout diagnostics', true),
)
const error = messages.find((msg) => msg.type === 'error')
expect(error).toMatchObject({
code: 'CLI_START_FAILED',
})
expect(error?.message).toContain(
'CLI exited during startup (code 1): provider rejected request: invalid model id',
)
expect(error?.message).toContain('Desktop service diagnostics:')
}, 10_000)
it('should prewarm the CLI before the first user turn and reuse that process', async () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',

View File

@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { gunzipSync } from 'node:zlib'
import { handleDiagnosticsApi } from '../api/diagnostics.js'
import { DiagnosticsService, diagnosticsService } from '../services/diagnosticsService.js'
let tmpDir: string
let originalConfigDir: string | undefined
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-diagnostics-test-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tmpDir
})
afterEach(async () => {
diagnosticsService.restoreConsoleCaptureForTests()
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
await fs.rm(tmpDir, { recursive: true, force: true })
})
function makeRequest(method: string, urlStr: string): { req: Request; url: URL; segments: string[] } {
const url = new URL(urlStr, 'http://localhost:3456')
const req = new Request(url.toString(), { method })
const segments = url.pathname.split('/').filter(Boolean)
return { req, url, segments }
}
describe('DiagnosticsService', () => {
test('writes sanitized structured events and runtime error summaries', async () => {
const service = new DiagnosticsService()
await service.recordEvent({
type: 'cli_start_failed',
severity: 'error',
sessionId: 'session-1',
summary: 'Authorization: Bearer sk-secret-token /Users/example/path',
details: {
apiKey: 'sk-secret',
url: 'https://api.example.com?api_key=secret-value',
nested: { message: `home=${os.homedir()}` },
},
})
const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'diagnostics.jsonl'), 'utf-8')
expect(raw).toContain('cli_start_failed')
expect(raw).toContain('[REDACTED]')
expect(raw).not.toContain('sk-secret')
expect(raw).not.toContain(os.homedir())
const runtime = await fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'runtime-errors.log'), 'utf-8')
expect(runtime).toContain('cli_start_failed')
expect(runtime).toContain('[REDACTED]')
expect(runtime).not.toContain('sk-secret-token')
})
test('exports a single diagnostics tarball without provider secrets', async () => {
const service = new DiagnosticsService()
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(
path.join(tmpDir, 'cc-haha', 'providers.json'),
JSON.stringify({
activeId: 'provider-1',
providers: [{
id: 'provider-1',
name: 'Test Provider',
presetId: 'custom',
apiKey: 'sk-provider-secret',
baseUrl: 'https://api.example.com/anthropic',
apiFormat: 'anthropic',
models: { main: 'main-model', haiku: 'haiku-model', sonnet: 'sonnet-model', opus: 'opus-model' },
}],
}),
'utf-8',
)
await service.recordEvent({
type: 'provider_test_failed',
severity: 'warn',
sessionId: 'session-abc',
summary: 'provider failed with token=provider-secret',
details: { accessToken: 'provider-secret' },
})
const bundle = await service.exportBundle()
expect(bundle.path).toEndWith('.tar.gz')
const archiveText = gunzipSync(await fs.readFile(bundle.path)).toString('utf-8')
expect(archiveText).toContain('README.txt')
expect(archiveText).toContain('providers-summary.json')
expect(archiveText).toContain('sessions-summary.json')
expect(archiveText).toContain('Test Provider')
expect(archiveText).toContain('api.example.com')
expect(archiveText).not.toContain('sk-provider-secret')
expect(archiveText).not.toContain('provider-secret')
})
})
describe('diagnostics API', () => {
test('returns status, events, export path, and supports clearing logs', async () => {
const service = diagnosticsService
await service.recordEvent({
type: 'api_unhandled_error',
severity: 'error',
summary: 'boom',
})
const statusReq = makeRequest('GET', '/api/diagnostics/status')
const statusRes = await handleDiagnosticsApi(statusReq.req, statusReq.url, statusReq.segments)
expect(statusRes.status).toBe(200)
const status = await statusRes.json() as { logDir: string; recentErrorCount: number }
expect(status.logDir).toContain(path.join('cc-haha', 'diagnostics'))
expect(status.recentErrorCount).toBe(1)
const eventsReq = makeRequest('GET', '/api/diagnostics/events?limit=10')
const eventsRes = await handleDiagnosticsApi(eventsReq.req, eventsReq.url, eventsReq.segments)
expect(eventsRes.status).toBe(200)
const events = await eventsRes.json() as { events: Array<{ type: string }> }
expect(events.events[0].type).toBe('api_unhandled_error')
const exportReq = makeRequest('POST', '/api/diagnostics/export')
const exportRes = await handleDiagnosticsApi(exportReq.req, exportReq.url, exportReq.segments)
expect(exportRes.status).toBe(200)
const exported = await exportRes.json() as { bundle: { path: string } }
await expect(fs.stat(exported.bundle.path)).resolves.toBeTruthy()
const clearReq = makeRequest('DELETE', '/api/diagnostics')
const clearRes = await handleDiagnosticsApi(clearReq.req, clearReq.url, clearReq.segments)
expect(clearRes.status).toBe(200)
expect(await service.readRecentEvents()).toEqual([])
})
})

View File

@ -28,6 +28,8 @@ const initMode = process.env.MOCK_SDK_INIT_MODE || 'on_open'
const streamDelayMs = Number(process.env.MOCK_SDK_STREAM_DELAY_MS || '0')
const exitAfterOpenMs = Number(process.env.MOCK_SDK_EXIT_AFTER_OPEN_MS || '0')
const exitAfterFirstUserMs = Number(process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS || '0')
const startupStdout = process.env.MOCK_SDK_STARTUP_STDOUT || ''
const exitBeforeSdkMs = Number(process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS || '0')
let initSent = false
let firstUserExitScheduled = false
@ -36,6 +38,14 @@ if (!sdkUrl) {
process.exit(1)
}
if (startupStdout) {
console.log(startupStdout)
}
if (exitBeforeSdkMs > 0) {
setTimeout(() => process.exit(1), exitBeforeSdkMs)
}
const ws = new WebSocket(sdkUrl)
function sendInit() {

View File

@ -0,0 +1,50 @@
/**
* Diagnostics REST API
*
* GET /api/diagnostics/status log directory, retention and counters
* GET /api/diagnostics/events recent sanitized diagnostic events
* POST /api/diagnostics/export write a sanitized tar.gz bundle
* POST /api/diagnostics/open-log-dir open the diagnostics directory
* DELETE /api/diagnostics clear diagnostics files
*/
import { diagnosticsService } from '../services/diagnosticsService.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
export async function handleDiagnosticsApi(
req: Request,
url: URL,
segments: string[],
): Promise<Response> {
try {
const action = segments[2]
if (!action && req.method === 'DELETE') {
await diagnosticsService.clear()
return Response.json({ ok: true })
}
if (action === 'status' && req.method === 'GET') {
return Response.json(await diagnosticsService.getStatus())
}
if (action === 'events' && req.method === 'GET') {
const limit = Number.parseInt(url.searchParams.get('limit') || '100', 10)
const events = await diagnosticsService.readRecentEvents(Number.isFinite(limit) ? limit : 100)
return Response.json({ events })
}
if (action === 'export' && req.method === 'POST') {
return Response.json({ bundle: await diagnosticsService.exportBundle() })
}
if (action === 'open-log-dir' && req.method === 'POST') {
await diagnosticsService.openLogDir()
return Response.json({ ok: true })
}
throw new ApiError(404, `Unknown diagnostics endpoint: ${action ?? '(root)'}`, 'NOT_FOUND')
} catch (error) {
return errorResponse(error)
}
}

View File

@ -24,6 +24,7 @@ import {
TestProviderSchema,
} from '../types/provider.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { diagnosticsService } from '../services/diagnosticsService.js'
const providerService = new ProviderService()
@ -99,6 +100,21 @@ export async function handleProvidersApi(
if (body && typeof body === 'object') overrides = body as typeof overrides
} catch { /* no body is fine — uses saved values */ }
const result = await providerService.testProvider(id, overrides)
if (!result.connectivity.success || result.proxy?.success === false) {
void diagnosticsService.recordEvent({
type: 'provider_test_failed',
severity: 'warn',
summary: result.connectivity.error || result.proxy?.error || 'Provider test failed',
details: {
providerId: id,
httpStatus: result.connectivity.httpStatus ?? result.proxy?.httpStatus,
apiFormat: overrides?.apiFormat,
modelId: overrides?.modelId,
connectivity: result.connectivity,
proxy: result.proxy,
},
})
}
return Response.json({ result })
}
@ -150,8 +166,31 @@ async function handleTestUnsaved(req: Request): Promise<Response> {
try {
const input = TestProviderSchema.parse(body)
const result = await providerService.testProviderConfig(input)
if (!result.connectivity.success || result.proxy?.success === false) {
void diagnosticsService.recordEvent({
type: 'provider_test_failed',
severity: 'warn',
summary: result.connectivity.error || result.proxy?.error || 'Provider test failed',
details: {
providerId: null,
baseUrl: input.baseUrl,
apiFormat: input.apiFormat,
modelId: input.modelId,
connectivity: result.connectivity,
proxy: result.proxy,
},
})
}
return Response.json({ result })
} catch (err) {
if (!(err instanceof z.ZodError)) {
void diagnosticsService.recordEvent({
type: 'provider_test_failed',
severity: 'warn',
summary: err instanceof Error ? err.message : String(err),
details: { providerId: null, error: err },
})
}
if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
throw err
}

View File

@ -16,6 +16,7 @@ import { ProviderService } from './services/providerService.js'
import { handleHahaOAuthCallback } from './api/haha-oauth.js'
import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncherService.js'
import { enableConfigs } from '../utils/config.js'
import { diagnosticsService } from './services/diagnosticsService.js'
function readArgValue(flag: string): string | undefined {
const args = process.argv.slice(2)
@ -48,6 +49,7 @@ const HOST = SERVER_OPTIONS.host
export function startServer(port = PORT, host = HOST) {
enableConfigs()
diagnosticsService.installConsoleCapture()
ProviderService.setServerPort(port)
const localConnectHost =
host === '0.0.0.0' || host === '127.0.0.1' || host === 'localhost'
@ -163,6 +165,12 @@ export function startServer(port = PORT, host = HOST) {
headers,
})
} catch (error) {
void diagnosticsService.recordEvent({
type: 'api_request_failed',
severity: 'error',
summary: error instanceof Error ? error.message : String(error),
details: { path: url.pathname, method: req.method, error },
})
console.error('[Server] API error:', error)
return Response.json(
{ error: 'Internal server error' },
@ -194,6 +202,12 @@ export function startServer(port = PORT, host = HOST) {
headers,
})
} catch (error) {
void diagnosticsService.recordEvent({
type: 'proxy_request_failed',
severity: 'error',
summary: error instanceof Error ? error.message : String(error),
details: { path: url.pathname, method: req.method, error },
})
console.error('[Server] Proxy error:', error)
return Response.json(
{ type: 'error', error: { type: 'api_error', message: 'Internal proxy error' } },

View File

@ -2,6 +2,8 @@
* Unified error handling utilities
*/
import { diagnosticsService } from '../services/diagnosticsService.js'
export class ApiError extends Error {
constructor(
public statusCode: number,
@ -37,6 +39,12 @@ export function errorResponse(error: unknown): Response {
)
}
void diagnosticsService.recordEvent({
type: 'api_unhandled_error',
severity: 'error',
summary: error instanceof Error ? error.message : String(error),
details: error,
})
console.error('[Server] Unexpected error:', error)
return Response.json(
{ error: 'INTERNAL_ERROR', message: 'An unexpected error occurred' },

View File

@ -19,6 +19,7 @@ import { handleSkillsApi } from './api/skills.js'
import { handleComputerUseApi } from './api/computer-use.js'
import { handleHahaOAuthApi } from './api/haha-oauth.js'
import { handleMcpApi } from './api/mcp.js'
import { handleDiagnosticsApi } from './api/diagnostics.js'
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
const path = url.pathname
@ -87,6 +88,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
case 'computer-use':
return handleComputerUseApi(req, url, segments)
case 'diagnostics':
return handleDiagnosticsApi(req, url, segments)
case 'filesystem':
return handleFilesystemRoute(url.pathname, url)

View File

@ -11,6 +11,7 @@ import * as os from 'node:os'
import * as path from 'node:path'
import { ProviderService } from './providerService.js'
import { sessionService } from './sessionService.js'
import { diagnosticsService } from './diagnosticsService.js'
import {
buildClaudeCliArgs,
resolveClaudeCliLauncher,
@ -32,7 +33,11 @@ type SessionProcess = {
sdkToken: string
sdkSocket: { send(data: string): void } | null
pendingOutbound: string[]
startupPending: boolean
startupExitCode: number | null
stdoutLines: string[]
stderrLines: string[]
outputDrain: Promise<void>
sdkMessages: any[]
initMessage: any | null
pendingPermissionRequests: Map<
@ -168,10 +173,23 @@ export class ConversationService {
cwd: workDir,
env: childEnv,
stdin: 'pipe',
stdout: 'ignore', // CLI communicates via SDK WebSocket, not stdout
stdout: 'pipe',
stderr: 'pipe',
})
} catch (spawnErr) {
void diagnosticsService.recordEvent({
type: 'cli_spawn_failed',
severity: 'error',
sessionId,
summary: spawnErr instanceof Error ? spawnErr.message : String(spawnErr),
details: {
workDir,
permissionMode: options?.permissionMode || 'default',
providerId: options?.providerId ?? null,
model: options?.model ?? null,
error: spawnErr,
},
})
throw new ConversationStartupError(
`Failed to spawn CLI in ${workDir}: ${
spawnErr instanceof Error ? spawnErr.message : String(spawnErr)
@ -188,17 +206,24 @@ export class ConversationService {
sdkToken: this.getSdkTokenFromUrl(sdkUrl),
sdkSocket: null,
pendingOutbound: [],
startupPending: true,
startupExitCode: null,
stdoutLines: [],
stderrLines: [],
outputDrain: Promise.resolve(),
sdkMessages: [],
initMessage: null,
pendingPermissionRequests: new Map(),
}
this.sessions.set(sessionId, session)
this.readErrorStream(sessionId, proc)
session.outputDrain = Promise.all([
this.readProcessOutputStream(sessionId, proc.stdout, 'stdout'),
this.readProcessOutputStream(sessionId, proc.stderr, 'stderr'),
]).then(() => undefined)
proc.exited.then((code) => {
this.handleProcessExit(sessionId, proc, code)
void this.handleProcessExit(sessionId, proc, code)
})
const STARTUP_GRACE_MS = 3000
@ -209,8 +234,10 @@ export class ConversationService {
),
])
if (earlyExitCode !== null) {
const startupError = this.buildStartupError(sessionId, earlyExitCode)
const startupExitCode = earlyExitCode ?? session.startupExitCode
if (startupExitCode !== null) {
await this.waitForProcessOutputDrain(session)
const startupError = this.buildStartupError(sessionId, startupExitCode)
this.sessions.delete(sessionId)
if (this.clearStaleLock(sessionId)) {
@ -221,11 +248,30 @@ export class ConversationService {
}
console.error(
`[ConversationService] CLI exited with code ${earlyExitCode} for ${sessionId}: ${startupError.message}`,
`[ConversationService] CLI exited with code ${startupExitCode} for ${sessionId}: ${startupError.message}`,
)
void diagnosticsService.recordEvent({
type: 'cli_start_failed',
severity: 'error',
sessionId,
summary: startupError.message,
details: {
code: startupError.code,
exitCode: startupExitCode,
retryable: startupError.retryable,
workDir,
permissionMode: options?.permissionMode || 'default',
providerId: options?.providerId ?? null,
model: options?.model ?? null,
capturedOutput: this.buildCapturedProcessOutputDetail(session),
sdkMessages: this.summarizeSdkMessages(session.sdkMessages),
},
})
throw startupError
}
session.startupPending = false
if (shouldReplacePlaceholder || !launchInfo) {
await sessionService.appendSessionMetadata(sessionId, {
workDir,
@ -503,13 +549,14 @@ export class ConversationService {
return Array.from(this.sessions.keys())
}
private async readErrorStream(
private async readProcessOutputStream(
sessionId: string,
proc: ReturnType<typeof Bun.spawn>,
stream: ReadableStream | null | undefined,
streamName: 'stdout' | 'stderr',
): Promise<void> {
if (!proc.stderr) return
if (!stream) return
const reader = (proc.stderr as ReadableStream).getReader()
const reader = stream.getReader()
const decoder = new TextDecoder()
try {
@ -526,20 +573,38 @@ export class ConversationService {
.split('\n')
.map((entry) => entry.trim())
.filter(Boolean)) {
session.stderrLines.push(line)
if (session.stderrLines.length > 20) {
session.stderrLines.splice(0, 10)
const lines =
streamName === 'stderr' ? session.stderrLines : session.stdoutLines
lines.push(this.redactProcessOutput(line))
if (lines.length > 20) {
lines.splice(0, 10)
}
}
}
console.error(`[CLI:${sessionId}] ${text.trim()}`)
const logLine = this.redactProcessOutput(text.trim())
if (streamName === 'stderr') {
console.error(`[CLI:${sessionId}:stderr] ${logLine}`)
} else {
console.log(`[CLI:${sessionId}:stdout] ${logLine}`)
}
}
} catch {
// stderr read failures should not kill the session
// Process output read failures should not kill the session.
}
}
private async waitForProcessOutputDrain(
session: SessionProcess,
timeoutMs = 250,
): Promise<void> {
const outputDrain = session.outputDrain ?? Promise.resolve()
await Promise.race([
outputDrain.catch(() => undefined),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
])
}
private sendSdkMessage(
sessionId: string,
payload: Record<string, unknown>,
@ -556,18 +621,36 @@ export class ConversationService {
return true
}
private handleProcessExit(
private async handleProcessExit(
sessionId: string,
proc: SessionProcess['proc'],
code: number,
): void {
): Promise<void> {
console.log(
`[ConversationService] CLI process for ${sessionId} exited with code ${code}`,
)
const activeSession = this.sessions.get(sessionId)
if (activeSession?.proc === proc) {
if (activeSession.startupPending) {
activeSession.startupExitCode = code
return
}
await this.waitForProcessOutputDrain(activeSession)
const exitError = this.buildRuntimeExitMessage(sessionId, code)
void diagnosticsService.recordEvent({
type: 'cli_runtime_exit',
severity: 'error',
sessionId,
summary: exitError,
details: {
exitCode: code,
workDir: activeSession.workDir,
permissionMode: activeSession.permissionMode,
capturedOutput: this.buildCapturedProcessOutputDetail(activeSession),
sdkMessages: this.summarizeSdkMessages(activeSession.sdkMessages),
},
})
for (const cb of activeSession.outputCallbacks) {
cb({
type: 'result',
@ -854,7 +937,7 @@ export class ConversationService {
exitCode: number,
): ConversationStartupError {
const session = this.sessions.get(sessionId)
const stderrText = session?.stderrLines.join('\n') ?? ''
const capturedOutput = this.buildCapturedProcessOutputDetail(session)
const recentMessages = session?.sdkMessages ?? []
const resultMessage = [...recentMessages]
.reverse()
@ -865,7 +948,7 @@ export class ConversationService {
const detail =
this.extractStartupDetail(resultMessage) ||
this.extractStartupDetail(authStatus) ||
stderrText
capturedOutput
if (
/(not logged in|run \/login|sign in again|login required|unauthenticated|logged_out)/i.test(
@ -890,7 +973,7 @@ export class ConversationService {
return new ConversationStartupError(
normalizedDetail
? `CLI exited during startup (code ${exitCode}): ${normalizedDetail}`
: `CLI exited during startup with code ${exitCode}.`,
: `CLI exited during startup with code ${exitCode}; no CLI stderr/stdout or SDK error payload was captured before exit.`,
'CLI_START_FAILED',
true,
)
@ -898,7 +981,7 @@ export class ConversationService {
private buildRuntimeExitMessage(sessionId: string, exitCode: number): string {
const session = this.sessions.get(sessionId)
const stderrText = session?.stderrLines.join('\n').trim() ?? ''
const capturedOutput = this.buildCapturedProcessOutputDetail(session)
const recentMessages = session?.sdkMessages ?? []
const resultMessage = [...recentMessages]
.reverse()
@ -909,11 +992,33 @@ export class ConversationService {
const detail =
this.extractStartupDetail(resultMessage) ||
this.extractStartupDetail(authStatus) ||
stderrText
capturedOutput
return detail
? `CLI process exited unexpectedly (code ${exitCode}): ${detail}`
: `CLI process exited unexpectedly with code ${exitCode}.`
: `CLI process exited unexpectedly with code ${exitCode}; no CLI stderr/stdout or SDK error payload was captured before exit.`
}
private buildCapturedProcessOutputDetail(
session: SessionProcess | undefined,
): string {
if (!session) return ''
const stderrText = (session.stderrLines ?? []).join('\n').trim()
const stdoutText = (session.stdoutLines ?? []).join('\n').trim()
if (stderrText && stdoutText) {
return `stderr:\n${stderrText}\nstdout:\n${stdoutText}`
}
return stderrText || stdoutText
}
private redactProcessOutput(line: string): string {
return line
.replace(/(ANTHROPIC_(?:API_KEY|AUTH_TOKEN)\s*[:=]\s*)[^\s,;]+/gi, '$1[REDACTED]')
.replace(/((?:api[_-]?key|auth[_-]?token|access[_-]?token)\s*[:=]\s*)[^\s,;]+/gi, '$1[REDACTED]')
.replace(/(Bearer\s+)[A-Za-z0-9._~+/-]+/gi, '$1[REDACTED]')
}
private extractStartupDetail(message: any): string {
@ -932,6 +1037,22 @@ export class ConversationService {
return ''
}
private summarizeSdkMessages(messages: any[]): unknown[] {
return messages.slice(-10).map((message) => {
if (!message || typeof message !== 'object') {
return message
}
return {
type: message.type,
subtype: message.subtype,
is_error: message.is_error,
status: typeof message.status === 'string' ? message.status : undefined,
result: typeof message.result === 'string' ? message.result : undefined,
message: typeof message.message === 'string' ? message.message : undefined,
}
})
}
private buildUserContent(
content: string,
sessionId: string,

View File

@ -0,0 +1,510 @@
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { gzipSync } from 'node:zlib'
import type { Dirent } from 'node:fs'
export type DiagnosticSeverity = 'debug' | 'info' | 'warn' | 'error'
export type DiagnosticEventInput = {
type: string
severity?: DiagnosticSeverity
summary: string
sessionId?: string
details?: unknown
}
export type DiagnosticEvent = {
id: string
timestamp: string
type: string
severity: DiagnosticSeverity
summary: string
sessionId?: string
details?: unknown
}
export type DiagnosticsStatus = {
logDir: string
diagnosticsPath: string
runtimeErrorsPath: string
exportDir: string
retentionDays: number
maxBytes: number
totalBytes: number
eventCount: number
recentErrorCount: number
lastEventAt: string | null
}
export type DiagnosticsExportResult = {
path: string
fileName: string
bytes: number
}
const RETENTION_DAYS = 7
const MAX_BYTES = 50 * 1024 * 1024
const MAX_STRING_LENGTH = 4096
const MAX_ARRAY_ITEMS = 40
const MAX_OBJECT_KEYS = 80
const MAX_EVENTS_IN_EXPORT = 5000
const SENSITIVE_KEY_RE = /(api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?token|\btoken\b|secret|password|authorization|cookie|oauth)/i
export class DiagnosticsService {
private consoleCaptureInstalled = false
private originalConsoleError: typeof console.error | null = null
private originalConsoleWarn: typeof console.warn | null = null
getLogDir(): string {
return path.join(this.getConfigDir(), 'cc-haha', 'diagnostics')
}
getDiagnosticsPath(): string {
return path.join(this.getLogDir(), 'diagnostics.jsonl')
}
getRuntimeErrorsPath(): string {
return path.join(this.getLogDir(), 'runtime-errors.log')
}
getExportDir(): string {
return path.join(this.getLogDir(), 'exports')
}
async recordEvent(input: DiagnosticEventInput): Promise<void> {
const event: DiagnosticEvent = {
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
type: input.type,
severity: input.severity ?? 'error',
summary: this.sanitizeString(input.summary),
...(input.sessionId ? { sessionId: this.sanitizeString(input.sessionId, 256) } : {}),
...(input.details !== undefined ? { details: this.sanitizeValue(input.details) } : {}),
}
try {
await this.ensureLogDir()
await fs.appendFile(this.getDiagnosticsPath(), JSON.stringify(event) + '\n', 'utf-8')
if (event.severity === 'warn' || event.severity === 'error') {
await fs.appendFile(this.getRuntimeErrorsPath(), this.formatRuntimeLogLine(event), 'utf-8')
}
await this.enforceRetention().catch(() => {})
} catch {
// Diagnostics must never break the product path.
}
}
installConsoleCapture(): void {
if (this.consoleCaptureInstalled) return
this.consoleCaptureInstalled = true
this.originalConsoleError = console.error.bind(console)
this.originalConsoleWarn = console.warn.bind(console)
console.error = (...args: unknown[]) => {
this.originalConsoleError?.(...args)
void this.recordEvent({
type: 'console_error',
severity: 'error',
summary: this.formatConsoleArgs(args),
})
}
console.warn = (...args: unknown[]) => {
this.originalConsoleWarn?.(...args)
void this.recordEvent({
type: 'console_warn',
severity: 'warn',
summary: this.formatConsoleArgs(args),
})
}
}
restoreConsoleCaptureForTests(): void {
if (this.originalConsoleError) console.error = this.originalConsoleError
if (this.originalConsoleWarn) console.warn = this.originalConsoleWarn
this.consoleCaptureInstalled = false
this.originalConsoleError = null
this.originalConsoleWarn = null
}
async getStatus(): Promise<DiagnosticsStatus> {
await this.ensureLogDir()
const events = await this.readRecentEvents(500)
const totalBytes = await this.getDirectorySize(this.getLogDir())
const cutoff = Date.now() - 24 * 60 * 60 * 1000
return {
logDir: this.getLogDir(),
diagnosticsPath: this.getDiagnosticsPath(),
runtimeErrorsPath: this.getRuntimeErrorsPath(),
exportDir: this.getExportDir(),
retentionDays: RETENTION_DAYS,
maxBytes: MAX_BYTES,
totalBytes,
eventCount: events.length,
recentErrorCount: events.filter((event) =>
(event.severity === 'error' || event.severity === 'warn') &&
Date.parse(event.timestamp) >= cutoff
).length,
lastEventAt: events[0]?.timestamp ?? null,
}
}
async readRecentEvents(limit = 100): Promise<DiagnosticEvent[]> {
const boundedLimit = Math.max(1, Math.min(limit, 1000))
let raw = ''
try {
raw = await fs.readFile(this.getDiagnosticsPath(), 'utf-8')
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
throw err
}
return raw
.split('\n')
.filter(Boolean)
.slice(-boundedLimit)
.map((line) => {
try {
return JSON.parse(line) as DiagnosticEvent
} catch {
return null
}
})
.filter((event): event is DiagnosticEvent => event !== null)
.reverse()
}
async exportBundle(): Promise<DiagnosticsExportResult> {
await this.ensureLogDir()
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const fileName = `cc-haha-diagnostics-${timestamp}.tar.gz`
const outPath = path.join(this.getExportDir(), fileName)
const events = await this.readRecentEvents(MAX_EVENTS_IN_EXPORT)
const files = [
{
name: 'README.txt',
content: this.buildReadme(),
},
{
name: 'app-info.json',
content: JSON.stringify(this.buildAppInfo(), null, 2) + '\n',
},
{
name: 'diagnostics.jsonl',
content: events.map((event) => JSON.stringify(this.sanitizeValue(event))).join('\n') + (events.length ? '\n' : ''),
},
{
name: 'runtime-errors.log',
content: await this.readSanitizedTextFile(this.getRuntimeErrorsPath()),
},
{
name: 'providers-summary.json',
content: JSON.stringify(await this.buildProvidersSummary(), null, 2) + '\n',
},
{
name: 'sessions-summary.json',
content: JSON.stringify(this.buildSessionsSummary(events), null, 2) + '\n',
},
]
const archive = this.createTarGz(files)
await fs.mkdir(this.getExportDir(), { recursive: true })
await fs.writeFile(outPath, archive)
return { path: outPath, fileName, bytes: archive.byteLength }
}
async openLogDir(): Promise<void> {
await this.ensureLogDir()
const dir = this.getLogDir()
if (process.platform === 'darwin') {
Bun.spawn(['open', dir], { stdout: 'ignore', stderr: 'ignore' })
return
}
if (process.platform === 'win32') {
Bun.spawn(['cmd', '/c', 'start', '', dir], { stdout: 'ignore', stderr: 'ignore' })
return
}
Bun.spawn(['xdg-open', dir], { stdout: 'ignore', stderr: 'ignore' })
}
async clear(): Promise<void> {
await fs.rm(this.getLogDir(), { recursive: true, force: true })
await this.ensureLogDir()
}
sanitizeValue(value: unknown, depth = 0): unknown {
if (depth > 6) return '[TRUNCATED_DEPTH]'
if (value === null || value === undefined) return value
if (typeof value === 'string') return this.sanitizeString(value)
if (typeof value === 'number' || typeof value === 'boolean') return value
if (typeof value === 'bigint') return value.toString()
if (value instanceof Error) {
return {
name: value.name,
message: this.sanitizeString(value.message),
stack: value.stack ? this.sanitizeString(value.stack) : undefined,
}
}
if (Array.isArray(value)) {
return value.slice(0, MAX_ARRAY_ITEMS).map((entry) => this.sanitizeValue(entry, depth + 1))
}
if (typeof value === 'object') {
const result: Record<string, unknown> = {}
let count = 0
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
if (count >= MAX_OBJECT_KEYS) {
result.__truncatedKeys = true
break
}
count += 1
result[key] = SENSITIVE_KEY_RE.test(key)
? '[REDACTED]'
: this.sanitizeValue(entry, depth + 1)
}
return result
}
return String(value)
}
sanitizeString(value: string, maxLength = MAX_STRING_LENGTH): string {
let sanitized = value
.replace(/(Bearer\s+)[A-Za-z0-9._~+/-]+/gi, '$1[REDACTED]')
.replace(/((?:api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?token|token|secret|password)\s*[:=]\s*)[^\s,;"'}]+/gi, '$1[REDACTED]')
.replace(/(ANTHROPIC_(?:API_KEY|AUTH_TOKEN)\s*[:=]\s*)[^\s,;"'}]+/gi, '$1[REDACTED]')
.replace(/([?&](?:api[_-]?key|token|auth|access_token|refresh_token|key)=)[^&\s]+/gi, '$1[REDACTED]')
const home = os.homedir()
if (home && sanitized.includes(home)) {
sanitized = sanitized.split(home).join('~')
}
if (sanitized.length > maxLength) {
return `${sanitized.slice(0, maxLength)}...[TRUNCATED ${sanitized.length - maxLength} chars]`
}
return sanitized
}
private async ensureLogDir(): Promise<void> {
await fs.mkdir(this.getExportDir(), { recursive: true })
}
private getConfigDir(): string {
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
}
private formatConsoleArgs(args: unknown[]): string {
return this.sanitizeString(args.map((arg) => {
if (arg instanceof Error) return `${arg.name}: ${arg.message}\n${arg.stack ?? ''}`
if (typeof arg === 'string') return arg
try {
return JSON.stringify(this.sanitizeValue(arg))
} catch {
return String(arg)
}
}).join(' '))
}
private formatRuntimeLogLine(event: DiagnosticEvent): string {
return `[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}: ${event.summary}\n`
}
private buildReadme(): string {
return [
'cc-haha diagnostics bundle',
'',
'This bundle is generated by the desktop app for debugging server and CLI startup/runtime failures.',
'It intentionally excludes chat prompts, assistant replies, file contents, attachments, full environment variables, API keys, bearer tokens, cookies, and OAuth tokens.',
'Paths under the current home directory are normalized to "~". Long fields are truncated.',
'',
'Files:',
'- app-info.json: runtime and platform summary.',
'- diagnostics.jsonl: sanitized structured diagnostic events.',
'- runtime-errors.log: sanitized warning/error summaries.',
'- providers-summary.json: provider count, active id, base URL host, model ids, and API format without API keys.',
'- sessions-summary.json: session ids observed in diagnostic events, without transcript content.',
'',
].join('\n')
}
private buildAppInfo(): Record<string, unknown> {
return this.sanitizeValue({
appVersion: process.env.APP_VERSION || '999.0.0-local',
platform: process.platform,
arch: process.arch,
node: process.version,
bun: typeof Bun !== 'undefined' ? Bun.version : null,
cwd: process.cwd(),
uptimeSeconds: Math.round(process.uptime()),
generatedAt: new Date().toISOString(),
}) as Record<string, unknown>
}
private async buildProvidersSummary(): Promise<Record<string, unknown>> {
const providerPath = path.join(this.getConfigDir(), 'cc-haha', 'providers.json')
try {
const raw = await fs.readFile(providerPath, 'utf-8')
const parsed = JSON.parse(raw) as {
activeId?: string | null
providers?: Array<Record<string, unknown>>
}
return {
activeId: parsed.activeId ?? null,
count: Array.isArray(parsed.providers) ? parsed.providers.length : 0,
providers: (parsed.providers ?? []).map((provider) => ({
id: provider.id,
name: provider.name,
presetId: provider.presetId,
apiFormat: provider.apiFormat,
baseUrl: this.summarizeUrl(typeof provider.baseUrl === 'string' ? provider.baseUrl : ''),
models: provider.models,
})),
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return { activeId: null, count: 0, providers: [] }
}
return { error: this.sanitizeString(err instanceof Error ? err.message : String(err)) }
}
}
private summarizeUrl(value: string): Record<string, string> | null {
if (!value.trim()) return null
try {
const url = new URL(value)
return { protocol: url.protocol, host: url.host, pathname: url.pathname }
} catch {
return { value: this.sanitizeString(value, 512) }
}
}
private buildSessionsSummary(events: DiagnosticEvent[]): Record<string, unknown> {
const sessions = new Map<string, { eventCount: number; lastEventAt: string; severities: Set<DiagnosticSeverity> }>()
for (const event of events) {
if (!event.sessionId) continue
const current = sessions.get(event.sessionId) ?? {
eventCount: 0,
lastEventAt: event.timestamp,
severities: new Set<DiagnosticSeverity>(),
}
current.eventCount += 1
current.lastEventAt = current.lastEventAt > event.timestamp ? current.lastEventAt : event.timestamp
current.severities.add(event.severity)
sessions.set(event.sessionId, current)
}
return {
count: sessions.size,
sessions: [...sessions.entries()].map(([sessionId, info]) => ({
sessionId,
eventCount: info.eventCount,
lastEventAt: info.lastEventAt,
severities: [...info.severities],
})),
transcriptContentIncluded: false,
}
}
private async readSanitizedTextFile(filePath: string): Promise<string> {
try {
return this.sanitizeString(await fs.readFile(filePath, 'utf-8'), 2 * MAX_STRING_LENGTH)
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return ''
throw err
}
}
private async enforceRetention(): Promise<void> {
const dir = this.getLogDir()
const cutoff = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000
const files = await this.listFiles(dir)
for (const file of files) {
if (file.mtimeMs < cutoff) {
await fs.rm(file.path, { force: true })
}
}
const remaining = (await this.listFiles(dir)).sort((a, b) => a.mtimeMs - b.mtimeMs)
let total = remaining.reduce((sum, file) => sum + file.size, 0)
for (const file of remaining) {
if (total <= MAX_BYTES) break
await fs.rm(file.path, { force: true })
total -= file.size
}
}
private async getDirectorySize(dir: string): Promise<number> {
return (await this.listFiles(dir)).reduce((sum, file) => sum + file.size, 0)
}
private async listFiles(dir: string): Promise<Array<{ path: string; size: number; mtimeMs: number }>> {
const results: Array<{ path: string; size: number; mtimeMs: number }> = []
let entries: Dirent[]
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
throw err
}
for (const entry of entries) {
const filePath = path.join(dir, entry.name)
if (entry.isDirectory()) {
results.push(...await this.listFiles(filePath))
continue
}
if (!entry.isFile()) continue
const stat = await fs.stat(filePath)
results.push({ path: filePath, size: stat.size, mtimeMs: stat.mtimeMs })
}
return results
}
private createTarGz(files: Array<{ name: string; content: string }>): Buffer {
const chunks: Buffer[] = []
const mtime = Math.floor(Date.now() / 1000)
for (const file of files) {
const body = Buffer.from(file.content, 'utf-8')
chunks.push(this.createTarHeader(file.name, body.byteLength, mtime))
chunks.push(body)
const padding = (512 - (body.byteLength % 512)) % 512
if (padding > 0) chunks.push(Buffer.alloc(padding))
}
chunks.push(Buffer.alloc(1024))
return gzipSync(Buffer.concat(chunks))
}
private createTarHeader(name: string, size: number, mtime: number): Buffer {
const header = Buffer.alloc(512)
this.writeTarString(header, 0, 100, name)
this.writeTarString(header, 100, 8, '0000644')
this.writeTarString(header, 108, 8, '0000000')
this.writeTarString(header, 116, 8, '0000000')
this.writeTarOctal(header, 124, 12, size)
this.writeTarOctal(header, 136, 12, mtime)
header.fill(0x20, 148, 156)
header[156] = '0'.charCodeAt(0)
this.writeTarString(header, 257, 6, 'ustar')
this.writeTarString(header, 263, 2, '00')
let checksum = 0
for (const byte of header) checksum += byte
const checksumValue = checksum.toString(8).padStart(6, '0')
header.write(checksumValue.slice(-6), 148, 6, 'ascii')
header[154] = 0
header[155] = 0x20
return header
}
private writeTarString(header: Buffer, offset: number, length: number, value: string): void {
header.write(value.slice(0, length), offset, length, 'utf-8')
}
private writeTarOctal(header: Buffer, offset: number, length: number, value: number): void {
const encoded = value.toString(8).padStart(length - 1, '0')
header.write(encoded.slice(-length + 1), offset, length - 1, 'ascii')
header[offset + length - 1] = 0
}
}
export const diagnosticsService = new DiagnosticsService()

View File

@ -17,6 +17,7 @@ import { computerUseApprovalService } from '../services/computerUseApprovalServi
import { sessionService } from '../services/sessionService.js'
import { SettingsService } from '../services/settingsService.js'
import { ProviderService } from '../services/providerService.js'
import { diagnosticsService } from '../services/diagnosticsService.js'
import { deriveTitle, generateTitle, saveAiTitle } from '../services/titleService.js'
import { parseSlashCommand } from '../../utils/slashCommandParsing.js'
import {
@ -134,6 +135,13 @@ export const handleWebSocket = {
switch (message.type) {
case 'user_message':
handleUserMessage(ws, message).catch((err) => {
void diagnosticsService.recordEvent({
type: 'ws_user_message_failed',
severity: 'error',
sessionId: ws.data.sessionId,
summary: err instanceof Error ? err.message : String(err),
details: err,
})
console.error(`[WS] Unhandled error in handleUserMessage:`, err)
})
break
@ -245,6 +253,13 @@ async function handleUserMessage(
await pendingRuntimeTransition
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err)
void diagnosticsService.recordEvent({
type: 'runtime_transition_failed',
severity: 'error',
sessionId,
summary: errMsg,
details: err,
})
console.error(`[WS] Runtime transition failed before handling user message for ${sessionId}: ${errMsg}`)
sendMessage(ws, {
type: 'error',
@ -514,6 +529,13 @@ async function restartSessionWithPermissionMode(
console.log(`[WS] Restarted CLI for ${sessionId} with permission mode: ${mode}`)
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err)
void diagnosticsService.recordEvent({
type: 'permission_restart_failed',
severity: 'error',
sessionId,
summary: errMsg,
details: { mode, error: err },
})
console.error(`[WS] Failed to restart CLI for ${sessionId}: ${errMsg}`)
sendMessage(ws, {
type: 'error',
@ -551,6 +573,13 @@ async function restartSessionWithRuntimeConfig(
console.log(`[WS] Restarted CLI for ${sessionId} with runtime override`)
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err)
void diagnosticsService.recordEvent({
type: 'runtime_config_restart_failed',
severity: 'error',
sessionId,
summary: errMsg,
details: { runtimeOverride: runtimeOverrides.get(sessionId), error: err },
})
console.error(`[WS] Failed to restart CLI for ${sessionId} after runtime override: ${errMsg}`)
sendMessage(ws, {
type: 'error',