diff --git a/desktop/src/__tests__/diagnosticsSettings.test.tsx b/desktop/src/__tests__/diagnosticsSettings.test.tsx
new file mode 100644
index 00000000..e5827770
--- /dev/null
+++ b/desktop/src/__tests__/diagnosticsSettings.test.tsx
@@ -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: () =>
,
+}))
+
+vi.mock('../pages/AdapterSettings', () => ({
+ AdapterSettings: () => ,
+}))
+
+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 }) => {code},
+}))
+
+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()
+
+ 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()
+
+ 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()
+
+ 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,
+ })
+ }
+ })
+})
diff --git a/desktop/src/api/diagnostics.ts b/desktop/src/api/diagnostics.ts
new file mode 100644
index 00000000..3fabac21
--- /dev/null
+++ b/desktop/src/api/diagnostics.ts
@@ -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('/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'),
+}
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index 76335ea0..11a426a7 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -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',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index 04da9b7b..7e01828e 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -96,6 +96,7 @@ export const zh: Record = {
'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 = {
'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 账号',
diff --git a/desktop/src/pages/DiagnosticsSettings.tsx b/desktop/src/pages/DiagnosticsSettings.tsx
new file mode 100644
index 00000000..83b2dd47
--- /dev/null
+++ b/desktop/src/pages/DiagnosticsSettings.tsx
@@ -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(null)
+ const [events, setEvents] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [isExporting, setIsExporting] = useState(false)
+ const [isClearing, setIsClearing] = useState(false)
+ const [lastExportPath, setLastExportPath] = useState(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 (
+
+
+
+
{t('settings.diagnostics.title')}
+
{t('settings.diagnostics.description')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{t('settings.diagnostics.logDirectory')}
+
{status?.logDir ?? '-'}
+
+
+
+
+
+
+
+ {lastExportPath && (
+
+ {lastExportPath}
+
+ )}
+
+
+
+
+
{t('settings.diagnostics.recentEvents')}
+
{t('settings.diagnostics.privacyNote')}
+
+
+
+ {events.length === 0 ? (
+
+ {isLoading ? t('common.loading') : t('settings.diagnostics.noEvents')}
+
+ ) : (
+
+ {events.map((event) => (
+
+ ))}
+
+ )}
+
+
+ )
+}
+
+function Metric({ label, value }: { label: string; value: string }) {
+ return (
+
+ )
+}
+
+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 (
+
+
+ {new Date(event.timestamp).toLocaleString()}
+
+
{event.severity}
+
+
+ {event.type}
+ {event.sessionId && (
+ {event.sessionId}
+ )}
+
+
{event.summary}
+
+
+ )
+}
diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx
index 4fc8b9f2..7881bd7e 100644
--- a/desktop/src/pages/Settings.tsx
+++ b/desktop/src/pages/Settings.tsx
@@ -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() {
setActiveTab('skills')} />
setActiveTab('plugins')} />
setActiveTab('computerUse')} />
+ setActiveTab('diagnostics')} />
setActiveTab('about')} />
@@ -76,6 +78,7 @@ export function Settings() {
{activeTab === 'skills' && }
{activeTab === 'plugins' && }
{activeTab === 'computerUse' && }
+ {activeTab === 'diagnostics' && }
{activeTab === 'about' && }
diff --git a/desktop/src/stores/uiStore.ts b/desktop/src/stores/uiStore.ts
index 1e0b91eb..0d9630b8 100644
--- a/desktop/src/stores/uiStore.ts
+++ b/desktop/src/stores/uiStore.ts
@@ -39,6 +39,7 @@ export type SettingsTab =
| 'skills'
| 'plugins'
| 'computerUse'
+ | 'diagnostics'
| 'about'
type ActiveView = 'code' | 'scheduled' | 'terminal' | 'history' | 'settings'
diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts
index 4d354fe7..fe8f1975 100644
--- a/src/server/__tests__/conversations.test.ts
+++ b/src/server/__tests__/conversations.test.ts
@@ -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(
+ stdout: string,
+ exitDelayMs: number,
+ callback: () => Promise,
+ ): Promise {
+ 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 {
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',
diff --git a/src/server/__tests__/diagnostics-service.test.ts b/src/server/__tests__/diagnostics-service.test.ts
new file mode 100644
index 00000000..2e4346db
--- /dev/null
+++ b/src/server/__tests__/diagnostics-service.test.ts
@@ -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([])
+ })
+})
diff --git a/src/server/__tests__/fixtures/mock-sdk-cli.ts b/src/server/__tests__/fixtures/mock-sdk-cli.ts
index 605aaaed..27c784e4 100644
--- a/src/server/__tests__/fixtures/mock-sdk-cli.ts
+++ b/src/server/__tests__/fixtures/mock-sdk-cli.ts
@@ -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() {
diff --git a/src/server/api/diagnostics.ts b/src/server/api/diagnostics.ts
new file mode 100644
index 00000000..ea32513c
--- /dev/null
+++ b/src/server/api/diagnostics.ts
@@ -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 {
+ 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)
+ }
+}
diff --git a/src/server/api/providers.ts b/src/server/api/providers.ts
index 2c574f5b..f2c94147 100644
--- a/src/server/api/providers.ts
+++ b/src/server/api/providers.ts
@@ -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 {
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
}
diff --git a/src/server/index.ts b/src/server/index.ts
index 98c9b69d..0de061d6 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -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' } },
diff --git a/src/server/middleware/errorHandler.ts b/src/server/middleware/errorHandler.ts
index 485ce66e..e03c4af7 100644
--- a/src/server/middleware/errorHandler.ts
+++ b/src/server/middleware/errorHandler.ts
@@ -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' },
diff --git a/src/server/router.ts b/src/server/router.ts
index 87853152..73c5bf7e 100644
--- a/src/server/router.ts
+++ b/src/server/router.ts
@@ -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 {
const path = url.pathname
@@ -87,6 +88,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise
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,
+ stream: ReadableStream | null | undefined,
+ streamName: 'stdout' | 'stderr',
): Promise {
- 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 {
+ const outputDrain = session.outputDrain ?? Promise.resolve()
+ await Promise.race([
+ outputDrain.catch(() => undefined),
+ new Promise((resolve) => setTimeout(resolve, timeoutMs)),
+ ])
+ }
+
private sendSdkMessage(
sessionId: string,
payload: Record,
@@ -556,18 +621,36 @@ export class ConversationService {
return true
}
- private handleProcessExit(
+ private async handleProcessExit(
sessionId: string,
proc: SessionProcess['proc'],
code: number,
- ): void {
+ ): Promise {
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,
diff --git a/src/server/services/diagnosticsService.ts b/src/server/services/diagnosticsService.ts
new file mode 100644
index 00000000..b57189f8
--- /dev/null
+++ b/src/server/services/diagnosticsService.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 = {}
+ let count = 0
+ for (const [key, entry] of Object.entries(value as Record)) {
+ 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 {
+ 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 {
+ 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
+ }
+
+ private async buildProvidersSummary(): Promise> {
+ 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>
+ }
+ 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 | 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 {
+ const sessions = new Map }>()
+ for (const event of events) {
+ if (!event.sessionId) continue
+ const current = sessions.get(event.sessionId) ?? {
+ eventCount: 0,
+ lastEventAt: event.timestamp,
+ severities: new Set(),
+ }
+ 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 {
+ 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 {
+ 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 {
+ return (await this.listFiles(dir)).reduce((sum, file) => sum + file.size, 0)
+ }
+
+ private async listFiles(dir: string): Promise> {
+ 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()
diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts
index 6c1f623e..980be3d6 100644
--- a/src/server/ws/handler.ts
+++ b/src/server/ws/handler.ts
@@ -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',