fix(diagnostics): harden diagnostics and recovery flows

Make diagnostics evidence bounded, share-safe, and resilient to concurrent
writers and corrupt segments. Improve Doctor repair safeguards, Electron
runtime recovery, Settings visibility, accessibility, and issue reporting.
This commit is contained in:
程序员阿江(Relakkes) 2026-07-12 00:25:54 +08:00
parent 347dbc3368
commit 5871188084
32 changed files with 3695 additions and 327 deletions

View File

@ -13,6 +13,7 @@ assignees: ''
- [ ] 我已经升级到最新版本后复现过这个问题
- [ ] 我已经搜索过[现有 issues](https://github.com/NanmiCoder/cc-haha/issues),确认没有重复问题
- [ ] 我已经隐藏截图和日志中的 API Key、Token、Cookie 等敏感信息
- [ ] 我已在分享前检查自动生成的诊断报告或诊断包,确认其中没有不应公开的私密元数据
## 问题描述
<!-- 请用 1-3 句话说明实际发生了什么、你原本期望发生什么 -->
@ -56,6 +57,16 @@ assignees: ''
在此粘贴错误信息和日志
```
## 诊断信息(可选)
<!--
桌面端可在「设置 -> 诊断」中复制 Event ID复制的 Issue 报告是 Markdown请粘贴到 Issue 正文中。导出的诊断包是文件,请作为附件上传。
自动生成的报告和诊断包采用尽力脱敏;请在公开分享前自行检查其中的路径、主机名及其他私密元数据。
-->
- 相关 Event IDs:
- 已粘贴到 Issue 正文的诊断报告: 是 / 否
- 诊断包文件附件:
## 截图或录屏
<!-- 请提供错误截图、配置截图或复现录屏;截图前请隐藏敏感信息 -->
@ -65,5 +76,3 @@ assignees: ''
---

View File

@ -4,6 +4,7 @@ import path from 'node:path'
import { ELECTRON_EVENT_CHANNELS, ELECTRON_INTERNAL_CHANNELS, ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from './ipc/channels'
import { isElectronIpcChannel, validateElectronIpcPayload } from './ipc/capabilities'
import { ElectronServerRuntime } from './services/serverRuntime'
import { electronHostDiagnosticsFile } from './services/sidecarManager'
import { openDialog, saveDialog } from './services/dialogs'
import { openExternalUrl, openSystemPath, openSystemSettingsUrl } from './services/shell'
import {
@ -139,6 +140,7 @@ function getServerRuntime() {
desktopRoot: unpackedRoot(),
appRoot: appRoot(),
h5DistDir: path.join(unpackedRoot(), 'dist'),
diagnosticsFile: electronHostDiagnosticsFile(process.env),
resolveSystemProxy: (url) => session.defaultSession.resolveProxy(url),
})
return serverRuntime

View File

@ -0,0 +1,334 @@
import { EventEmitter } from 'node:events'
import { mkdtempSync, rmSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import path from 'node:path'
import { PassThrough } from 'node:stream'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SidecarChild, SidecarPlan } from './sidecarManager'
import { ElectronServerRuntime } from './serverRuntime'
const sidecarMocks = {
nextPort: 49321,
spawnError: null as Error | null,
serverChildren: [] as FakeSidecarChild[],
adapterChildren: [] as FakeSidecarChild[],
serverPlans: [] as SidecarPlan[],
appendHostDiagnostic: vi.fn(),
waitForServerImpl: () => Promise.resolve(),
onAdapterSpawn: null as (() => void) | null,
spawnSidecar: vi.fn((plan: SidecarPlan) => {
if (plan.args[0] === 'server' && sidecarMocks.spawnError) throw sidecarMocks.spawnError
const child = new FakeSidecarChild()
if (plan.args[0] === 'server') {
sidecarMocks.serverChildren.push(child)
sidecarMocks.serverPlans.push(plan)
} else {
sidecarMocks.adapterChildren.push(child)
sidecarMocks.onAdapterSpawn?.()
}
return child as unknown as SidecarChild
}),
}
let isolatedConfigDir = ''
class FakeSidecarChild extends EventEmitter {
readonly stdout = new PassThrough()
readonly stderr = new PassThrough()
readonly kill = vi.fn()
}
function createRuntime(options: { appRoot?: string, diagnosticsFile?: string } = {}) {
return new ElectronServerRuntime({
desktopRoot: '/isolated/desktop',
appRoot: options.appRoot,
diagnosticsFile: options.diagnosticsFile,
env: { CLAUDE_CONFIG_DIR: isolatedConfigDir },
deps: {
appendHostDiagnostic: sidecarMocks.appendHostDiagnostic,
preferredServerPorts: () => [],
reserveServerPort: async () => sidecarMocks.nextPort++,
spawnSidecar: sidecarMocks.spawnSidecar,
waitForServer: async () => await sidecarMocks.waitForServerImpl(),
writeLastServerPort: () => undefined,
},
})
}
async function waitForServerChildren(count: number): Promise<void> {
for (let attempt = 0; attempt < 20 && sidecarMocks.serverChildren.length !== count; attempt++) {
await new Promise(resolve => setTimeout(resolve, 0))
}
expect(sidecarMocks.serverChildren).toHaveLength(count)
}
describe('ElectronServerRuntime', () => {
beforeEach(() => {
isolatedConfigDir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-runtime-'))
sidecarMocks.nextPort = 49321
sidecarMocks.spawnError = null
sidecarMocks.serverChildren.length = 0
sidecarMocks.adapterChildren.length = 0
sidecarMocks.serverPlans.length = 0
sidecarMocks.appendHostDiagnostic.mockClear()
sidecarMocks.waitForServerImpl = () => Promise.resolve()
sidecarMocks.onAdapterSpawn = null
sidecarMocks.spawnSidecar.mockClear()
})
afterEach(() => {
rmSync(isolatedConfigDir, { recursive: true, force: true })
})
it('restarts after the active healthy server exits and ignores its late exit', async () => {
const runtime = createRuntime({
appRoot: '/isolated/app',
})
const firstUrl = await runtime.getServerUrl()
const firstChild = sidecarMocks.serverChildren[0]!
const firstAdapters = [...sidecarMocks.adapterChildren]
expect(firstAdapters).toHaveLength(5)
firstChild.emit('exit', 7, null)
const [secondUrl, coalescedUrl] = await Promise.all([
runtime.getServerUrl(),
runtime.getServerUrl(),
])
const secondChild = sidecarMocks.serverChildren[1]!
firstChild.emit('exit', 9, 'SIGTERM')
expect(firstUrl).toBe('http://127.0.0.1:49321')
expect(secondUrl).toBe('http://127.0.0.1:49322')
expect(coalescedUrl).toBe(secondUrl)
expect(sidecarMocks.serverChildren).toHaveLength(2)
expect(sidecarMocks.adapterChildren).toHaveLength(10)
for (const adapter of firstAdapters) expect(adapter.kill).toHaveBeenCalledTimes(1)
for (const adapter of sidecarMocks.adapterChildren.slice(5)) {
expect(adapter.kill).not.toHaveBeenCalled()
}
expect(await runtime.getServerUrl()).toBe(secondUrl)
expect(secondChild).toBeDefined()
})
it('passes the isolated Electron host diagnostics file to the server sidecar', async () => {
const runtime = createRuntime({
diagnosticsFile: '/isolated/user-data/diagnostics/electron-host.log',
})
await runtime.startServer()
expect(sidecarMocks.serverPlans[0]!.env.CC_HAHA_ELECTRON_DIAGNOSTICS_FILE)
.toBe('/isolated/user-data/diagnostics/electron-host.log')
expect(sidecarMocks.serverPlans[0]!.env.CLAUDE_CONFIG_DIR).toBe(isolatedConfigDir)
expect(sidecarMocks.serverPlans[0]!.env.CLAUDE_CONFIG_DIR)
.not.toBe(path.join(homedir(), '.claude'))
})
it('persists a server startup failure through the sanitized host-log boundary', async () => {
sidecarMocks.spawnError = new Error('spawn failed')
const runtime = createRuntime({
diagnosticsFile: '/isolated/user-data/diagnostics/electron-host.log',
})
await expect(runtime.startServer()).rejects.toThrow('spawn failed')
expect(sidecarMocks.appendHostDiagnostic).toHaveBeenCalledWith(
'/isolated/user-data/diagnostics/electron-host.log',
expect.stringContaining('[startup-error] spawn failed'),
)
})
it('rejects an in-flight start when the child exits before health publication', async () => {
sidecarMocks.waitForServerImpl = () => new Promise(() => undefined)
const runtime = createRuntime()
const starting = runtime.startServer()
await waitForServerChildren(1)
sidecarMocks.serverChildren[0]!.emit('exit', 17, null)
await expect(starting).rejects.toThrow('code=17, signal=null')
sidecarMocks.waitForServerImpl = () => Promise.resolve()
await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49322')
expect(sidecarMocks.serverChildren).toHaveLength(2)
})
it('kills the attempted server child when the health wait rejects', async () => {
sidecarMocks.waitForServerImpl = () => Promise.reject(new Error('health wait timed out'))
const runtime = createRuntime()
await expect(runtime.startServer()).rejects.toThrow('health wait timed out')
expect(sidecarMocks.serverChildren).toHaveLength(1)
expect(sidecarMocks.serverChildren[0]!.kill).toHaveBeenCalledTimes(1)
expect(sidecarMocks.adapterChildren).toHaveLength(0)
})
it('kills an unpublished server exactly once when stopAll runs during health wait', async () => {
let releaseHealth!: () => void
sidecarMocks.waitForServerImpl = () => new Promise<void>(resolve => {
releaseHealth = resolve
})
const runtime = createRuntime()
const starting = runtime.startServer()
await waitForServerChildren(1)
runtime.stopAll(true)
expect(sidecarMocks.serverChildren[0]!.kill).toHaveBeenCalledTimes(1)
await expect(starting).rejects.toThrow('stopped')
releaseHealth()
await new Promise(resolve => setTimeout(resolve, 0))
expect(sidecarMocks.serverChildren).toHaveLength(1)
expect(sidecarMocks.adapterChildren).toHaveLength(0)
expect(sidecarMocks.serverChildren[0]!.kill).toHaveBeenCalledTimes(1)
})
it('stops active adapters immediately when the server exits without restart demand', async () => {
const runtime = createRuntime()
await runtime.startServer()
const activeAdapters = [...sidecarMocks.adapterChildren]
sidecarMocks.serverChildren[0]!.emit('exit', 19, null)
for (const adapter of activeAdapters) {
expect(adapter.kill).toHaveBeenCalledTimes(1)
}
expect(sidecarMocks.serverChildren).toHaveLength(1)
})
it('stops active adapters immediately when the server emits a process error', async () => {
const runtime = createRuntime()
await runtime.startServer()
const activeAdapters = [...sidecarMocks.adapterChildren]
sidecarMocks.serverChildren[0]!.emit('error', new Error('active server failed'))
for (const adapter of activeAdapters) {
expect(adapter.kill).toHaveBeenCalledTimes(1)
}
})
it('does not let a stale server exit stop replacement adapters', async () => {
const runtime = createRuntime()
await runtime.startServer()
const firstServer = sidecarMocks.serverChildren[0]!
firstServer.emit('exit', 20, null)
await runtime.getServerUrl()
const replacementAdapters = sidecarMocks.adapterChildren.slice(5)
firstServer.emit('exit', 21, 'SIGTERM')
expect(replacementAdapters).toHaveLength(5)
for (const adapter of replacementAdapters) {
expect(adapter.kill).not.toHaveBeenCalled()
}
})
it('stops the current adapter generation after an explicit adapter restart', async () => {
const runtime = createRuntime()
await runtime.startServer()
const firstAdapters = [...sidecarMocks.adapterChildren]
await runtime.restartAdaptersSidecars()
const restartedAdapters = sidecarMocks.adapterChildren.slice(5)
sidecarMocks.serverChildren[0]!.emit('exit', 22, null)
for (const adapter of firstAdapters) {
expect(adapter.kill).toHaveBeenCalledTimes(1)
}
for (const adapter of restartedAdapters) {
expect(adapter.kill).toHaveBeenCalledTimes(1)
}
})
it('coalesces overlapping manual adapter restarts into one live generation', async () => {
const runtime = createRuntime()
await runtime.startServer()
const originalAdapters = [...sidecarMocks.adapterChildren]
const firstRestart = runtime.restartAdaptersSidecars()
const secondRestart = runtime.restartAdaptersSidecars()
expect(secondRestart).toBe(firstRestart)
await Promise.all([firstRestart, secondRestart])
expect(sidecarMocks.adapterChildren).toHaveLength(10)
for (const adapter of originalAdapters) {
expect(adapter.kill).toHaveBeenCalledTimes(1)
}
for (const adapter of sidecarMocks.adapterChildren.slice(5)) {
expect(adapter.kill).not.toHaveBeenCalled()
}
})
it('cancels a manual adapter restart when its server exits after the first spawn', async () => {
const runtime = createRuntime()
await runtime.startServer()
const firstServer = sidecarMocks.serverChildren[0]!
const originalAdapters = [...sidecarMocks.adapterChildren]
sidecarMocks.onAdapterSpawn = () => {
sidecarMocks.onAdapterSpawn = null
firstServer.emit('exit', 23, null)
}
await runtime.restartAdaptersSidecars()
expect(sidecarMocks.adapterChildren).toHaveLength(6)
for (const adapter of originalAdapters) {
expect(adapter.kill).toHaveBeenCalledTimes(1)
}
expect(sidecarMocks.adapterChildren[5]!.kill).toHaveBeenCalledTimes(1)
await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49322')
expect(sidecarMocks.serverChildren).toHaveLength(2)
expect(sidecarMocks.adapterChildren).toHaveLength(11)
for (const adapter of sidecarMocks.adapterChildren.slice(6)) {
expect(adapter.kill).not.toHaveBeenCalled()
}
})
it('rejects when the published child exits during adapter startup', async () => {
const runtime = createRuntime()
sidecarMocks.onAdapterSpawn = () => {
sidecarMocks.onAdapterSpawn = null
sidecarMocks.serverChildren[0]!.emit('exit', 18, 'SIGTERM')
}
await expect(runtime.startServer()).rejects.toThrow('code=18, signal=SIGTERM')
expect(sidecarMocks.adapterChildren).toHaveLength(1)
expect(sidecarMocks.adapterChildren[0]!.kill).toHaveBeenCalledTimes(1)
await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49322')
expect(sidecarMocks.serverChildren).toHaveLength(2)
expect(sidecarMocks.adapterChildren).toHaveLength(6)
for (const adapter of sidecarMocks.adapterChildren.slice(1)) {
expect(adapter.kill).not.toHaveBeenCalled()
}
})
it('handles an asynchronous child process error without crashing Electron', async () => {
sidecarMocks.waitForServerImpl = () => new Promise(() => undefined)
const runtime = createRuntime({
diagnosticsFile: '/isolated/user-data/diagnostics/electron-host.log',
})
const starting = runtime.startServer()
await waitForServerChildren(1)
expect(() => sidecarMocks.serverChildren[0]!.emit(
'error',
new Error('spawn error OPENAI_API_KEY=unsafe-value'),
)).not.toThrow()
const rejection = await starting.then(
() => null,
error => error as Error,
)
expect(rejection?.message).toContain('spawn error')
expect(rejection?.message).not.toContain('unsafe-value')
expect(sidecarMocks.appendHostDiagnostic).toHaveBeenCalledWith(
'/isolated/user-data/diagnostics/electron-host.log',
expect.stringContaining('[process-error] sidecar process error: spawn error'),
)
})
})

View File

@ -1,7 +1,9 @@
import path from 'node:path'
import {
appendHostDiagnostic,
createAdapterPlan,
createServerPlan,
ELECTRON_DIAGNOSTICS_FILE_ENV,
formatStartupError,
killSidecar,
mergeProxyEnv,
@ -10,6 +12,7 @@ import {
proxyUrlFromElectronProxyRules,
pushStartupLog,
reserveServerPort,
sanitizeHostDiagnostic,
SERVER_BIND_HOST,
SERVER_CONTROL_HOST,
SERVER_STARTUP_TIMEOUT_MS,
@ -25,24 +28,91 @@ type ServerRuntimeOptions = {
desktopRoot: string
appRoot?: string
h5DistDir?: string
diagnosticsFile?: string
env?: NodeJS.ProcessEnv
deps?: Partial<ServerRuntimeDeps>
resolveSystemProxy?: (url: string) => Promise<string>
}
type ServerRuntimeDeps = {
appendHostDiagnostic: typeof appendHostDiagnostic
preferredServerPorts: typeof preferredServerPorts
reserveServerPort: typeof reserveServerPort
spawnSidecar: typeof spawnSidecar
waitForServer: typeof waitForServer
writeLastServerPort: typeof writeLastServerPort
}
const DEFAULT_SERVER_RUNTIME_DEPS: ServerRuntimeDeps = {
appendHostDiagnostic,
preferredServerPorts,
reserveServerPort,
spawnSidecar,
waitForServer,
writeLastServerPort,
}
type ServerStartState = {
child: SidecarChild
adapterChildren: SidecarChild[]
childStopped: boolean
readonly failure: Error | null
failurePromise: Promise<never>
fail: (error: Error) => void
}
type ActiveServer = {
url: string
child: SidecarChild
adapterChildren: SidecarChild[]
}
function createServerStartState(child: SidecarChild): ServerStartState {
let failure: Error | null = null
let rejectFailure!: (error: Error) => void
const failurePromise = new Promise<never>((_resolve, reject) => {
rejectFailure = reject
})
return {
child,
adapterChildren: [],
childStopped: false,
get failure() {
return failure
},
failurePromise,
fail(error) {
if (failure) return
failure = error
rejectFailure(error)
},
}
}
export class ElectronServerRuntime {
private readonly desktopRoot: string
private readonly appRoot: string
private readonly h5DistDir: string
private readonly diagnosticsFile?: string
private readonly baseEnv: NodeJS.ProcessEnv
private readonly deps: ServerRuntimeDeps
private readonly resolveSystemProxy?: (url: string) => Promise<string>
private sidecarEnvPromise: Promise<NodeJS.ProcessEnv> | null = null
private server: { url: string, child: SidecarChild } | null = null
private server: ActiveServer | null = null
private adapters: SidecarChild[] = []
private startupError: string | null = null
private restartAfterExit = false
private startPromise: Promise<string> | null = null
private startingServer: ServerStartState | null = null
private adapterRestartPromise: Promise<void> | null = null
constructor(options: ServerRuntimeOptions) {
this.desktopRoot = options.desktopRoot
this.appRoot = options.appRoot ?? options.desktopRoot
this.h5DistDir = options.h5DistDir ?? path.join(options.desktopRoot, 'dist')
this.diagnosticsFile = options.diagnosticsFile
this.baseEnv = options.env ?? process.env
this.deps = { ...DEFAULT_SERVER_RUNTIME_DEPS, ...options.deps }
this.resolveSystemProxy = options.resolveSystemProxy
}
@ -50,6 +120,7 @@ export class ElectronServerRuntime {
if (this.server) return this.server.url
if (this.startPromise) return this.startPromise
this.restartAfterExit = false
this.startPromise = this.startServerOnce()
try {
return await this.startPromise
@ -60,17 +131,41 @@ export class ElectronServerRuntime {
async getServerUrl(): Promise<string> {
if (this.server) return this.server.url
if (this.startupError) throw new Error(this.startupError)
if (this.startPromise) return await this.startServer()
if (this.startupError && !this.restartAfterExit) throw new Error(this.startupError)
return await this.startServer()
}
async restartAdaptersSidecars(): Promise<void> {
this.stopAdaptersSidecars()
restartAdaptersSidecars(): Promise<void> {
if (this.adapterRestartPromise) return this.adapterRestartPromise
const operation = this.restartAdaptersSidecarsOnce()
const tracked = operation.finally(() => {
if (this.adapterRestartPromise === tracked) this.adapterRestartPromise = null
})
this.adapterRestartPromise = tracked
return tracked
}
private async restartAdaptersSidecarsOnce(): Promise<void> {
const serverUrl = await this.getServerUrl()
await this.startAdaptersSidecars(serverUrl)
const server = this.server
if (!server || server.url !== serverUrl) return
this.stopAdapterChildren(server.adapterChildren)
await this.startAdaptersSidecars(serverUrl, undefined, server)
}
stopAll(sync = false) {
const starting = this.startingServer
if (starting) {
this.startingServer = null
this.stopAdaptersForStart(starting, sync)
if (this.server?.child === starting.child) this.server = null
starting.fail(new Error('server startup stopped'))
if (!starting.childStopped) {
starting.childStopped = true
killSidecar(starting.child, sync)
}
}
this.stopAdaptersSidecars(sync)
if (this.server) {
killSidecar(this.server.child, sync)
@ -81,36 +176,84 @@ export class ElectronServerRuntime {
private async startServerOnce(): Promise<string> {
// Prefer the configured fixed port, then the previous run's port, so
// phone bookmarks / QR codes / reverse proxies survive restarts (#767).
const port = await reserveServerPort(SERVER_BIND_HOST, preferredServerPorts())
const port = await this.deps.reserveServerPort(
SERVER_BIND_HOST,
this.deps.preferredServerPorts(this.baseEnv),
)
const url = `http://${SERVER_CONTROL_HOST}:${port}`
const logs: string[] = []
let startState: ServerStartState | null = null
const env = await this.resolveSidecarBaseEnv()
const plan = createServerPlan({
desktopRoot: this.desktopRoot,
appRoot: this.appRoot,
port,
h5DistDir: this.h5DistDir,
env,
env: this.diagnosticsFile
? { ...env, [ELECTRON_DIAGNOSTICS_FILE_ENV]: this.diagnosticsFile }
: env,
})
try {
const child = spawnSidecar(plan)
this.captureLogs(child, 'claude-server', logs)
await waitForServer(SERVER_CONTROL_HOST, port, SERVER_STARTUP_TIMEOUT_MS)
writeLastServerPort(port)
this.server = { url, child }
const child = this.deps.spawnSidecar(plan)
startState = createServerStartState(child)
this.startingServer = startState
this.captureLogs(child, 'claude-server', logs, (code, signal) => {
this.handleServerExit(child, code, signal, logs)
}, error => {
this.handleServerError(child, error, logs)
})
await Promise.race([
this.deps.waitForServer(SERVER_CONTROL_HOST, port, SERVER_STARTUP_TIMEOUT_MS),
startState.failurePromise,
])
if (startState.failure) throw startState.failure
this.deps.writeLastServerPort(port, this.baseEnv)
this.server = { url, child, adapterChildren: startState.adapterChildren }
const activeServer = this.server
this.startupError = null
await this.startAdaptersSidecars(url)
this.stopAdaptersSidecars()
await Promise.race([
this.startAdaptersSidecars(url, startState, activeServer),
startState.failurePromise,
])
if (startState.failure) throw startState.failure
return url
} catch (error) {
if (startState) {
this.stopAdaptersForStart(startState)
if (this.server?.child === startState.child) this.server = null
if (!startState.childStopped) {
startState.childStopped = true
killSidecar(startState.child)
}
}
if (startState?.failure) {
throw new Error(this.startupError ?? startState.failure.message)
}
const message = error instanceof Error ? error.message : String(error)
this.deps.appendHostDiagnostic(this.diagnosticsFile, `[claude-server] [startup-error] ${message}`)
this.startupError = formatStartupError(message, logs)
throw new Error(this.startupError)
} finally {
if (this.startingServer === startState) this.startingServer = null
}
}
private async startAdaptersSidecars(serverUrl: string): Promise<void> {
private async startAdaptersSidecars(
serverUrl: string,
startState?: ServerStartState,
activeServer?: ActiveServer,
): Promise<void> {
const env = await this.resolveSidecarBaseEnv()
const isCurrentGeneration = () => {
if (startState?.failure) return false
if (activeServer && this.server !== activeServer) return false
return true
}
if (!isCurrentGeneration()) return
const ownedAdapters = startState?.adapterChildren
?? activeServer?.adapterChildren
for (const [label, flag] of [
['feishu', '--feishu'],
['telegram', '--telegram'],
@ -118,8 +261,9 @@ export class ElectronServerRuntime {
['dingtalk', '--dingtalk'],
['whatsapp', '--whatsapp'],
] as const) {
if (!isCurrentGeneration()) break
try {
const child = spawnSidecar(createAdapterPlan({
const child = this.deps.spawnSidecar(createAdapterPlan({
desktopRoot: this.desktopRoot,
appRoot: this.appRoot,
h5DistDir: this.h5DistDir,
@ -127,8 +271,13 @@ export class ElectronServerRuntime {
flag,
env,
}))
if (!isCurrentGeneration()) {
killSidecar(child)
break
}
this.captureLogs(child, `claude-adapters:${label}`)
this.adapters.push(child)
ownedAdapters?.push(child)
} catch (error) {
console.error(`[desktop] failed to start ${label} adapter sidecar`, error)
}
@ -136,29 +285,104 @@ export class ElectronServerRuntime {
}
private stopAdaptersSidecars(sync = false) {
for (const child of this.adapters.splice(0)) {
const children = this.adapters.splice(0)
this.removeOwnedAdapters(this.server?.adapterChildren, children)
this.removeOwnedAdapters(this.startingServer?.adapterChildren, children)
for (const child of children) {
killSidecar(child, sync)
}
}
private captureLogs(child: SidecarChild, label: string, startupLogs?: string[]) {
private removeOwnedAdapters(owned: SidecarChild[] | undefined, removed: SidecarChild[]) {
if (!owned?.length || !removed.length) return
const removedSet = new Set(removed)
const retained = owned.filter(child => !removedSet.has(child))
owned.splice(0, owned.length, ...retained)
}
private stopAdaptersForStart(startState: ServerStartState, sync = false) {
this.stopAdapterChildren(startState.adapterChildren, sync)
}
private captureLogs(
child: SidecarChild,
label: string,
startupLogs?: string[],
onExit?: (code: number | null, signal: NodeJS.Signals | null) => void,
onError?: (error: Error) => void,
) {
child.stdout.on('data', chunk => {
const line = String(chunk).trimEnd()
if (!line) return
console.log(`[${label}] ${line}`)
this.deps.appendHostDiagnostic(this.diagnosticsFile, `[${label}] [stdout] ${line}`)
if (startupLogs) pushStartupLog(startupLogs, `[stdout] ${line}`)
})
child.stderr.on('data', chunk => {
const line = String(chunk).trimEnd()
if (!line) return
console.error(`[${label}] ${line}`)
this.deps.appendHostDiagnostic(this.diagnosticsFile, `[${label}] [stderr] ${line}`)
if (startupLogs) pushStartupLog(startupLogs, `[stderr] ${line}`)
})
child.on('exit', (code, signal) => {
const line = `sidecar exited (code=${code}, signal=${signal})`
console.log(`[${label}] ${line}`)
this.deps.appendHostDiagnostic(this.diagnosticsFile, `[${label}] [exit] ${line}`)
if (startupLogs) pushStartupLog(startupLogs, `[exit] ${line}`)
onExit?.(code, signal)
})
child.on('error', error => {
const message = error instanceof Error ? error.message : String(error)
const line = `sidecar process error: ${message}`
console.error(`[${label}] ${sanitizeHostDiagnostic(line)}`)
this.deps.appendHostDiagnostic(this.diagnosticsFile, `[${label}] [process-error] ${line}`)
if (startupLogs) pushStartupLog(startupLogs, `[process-error] ${line}`)
onError?.(error instanceof Error ? error : new Error(message))
})
}
private handleServerExit(
child: SidecarChild,
code: number | null,
signal: NodeJS.Signals | null,
logs: string[],
) {
this.handleServerFailure(
child,
`server sidecar exited after spawn (code=${code}, signal=${signal})`,
logs,
)
}
private handleServerError(child: SidecarChild, error: Error, logs: string[]) {
this.handleServerFailure(
child,
`server sidecar process error after spawn: ${sanitizeHostDiagnostic(error.message)}`,
logs,
)
}
private handleServerFailure(child: SidecarChild, message: string, logs: string[]) {
const active = this.server?.child === child
const starting = this.startingServer?.child === child
if (!active && !starting) return
if (active) {
const adapterChildren = this.server!.adapterChildren
this.server = null
this.stopAdapterChildren(adapterChildren)
}
this.restartAfterExit = true
this.startupError = formatStartupError(message, logs)
if (starting) this.startingServer?.fail(new Error(message))
}
private stopAdapterChildren(children: SidecarChild[], sync = false) {
for (const child of children.splice(0)) {
const index = this.adapters.indexOf(child)
if (index >= 0) this.adapters.splice(index, 1)
killSidecar(child, sync)
}
}
private async resolveSidecarBaseEnv(): Promise<NodeJS.ProcessEnv> {
@ -167,17 +391,17 @@ export class ElectronServerRuntime {
}
private async resolveSidecarBaseEnvOnce(): Promise<NodeJS.ProcessEnv> {
if (!this.resolveSystemProxy) return this.applyPowerShellOverride(process.env)
if (!this.resolveSystemProxy) return this.applyPowerShellOverride(this.baseEnv)
try {
const rules = await this.resolveSystemProxy('https://auth.openai.com/')
return this.applyPowerShellOverride(mergeProxyEnv(
process.env,
this.baseEnv,
proxyUrlFromElectronProxyRules(rules),
))
} catch (error) {
console.error('[desktop] failed to resolve system proxy for sidecars', error)
return this.applyPowerShellOverride(process.env)
return this.applyPowerShellOverride(this.baseEnv)
}
}

View File

@ -2,13 +2,17 @@ import { describe, expect, it, vi } from 'vitest'
import net from 'node:net'
import http from 'node:http'
import path from 'node:path'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import {
appendHostDiagnostic,
buildSidecarEnv,
createAdapterPlan,
createServerPlan,
electronHostDiagnosticsFile,
httpToWebSocketUrl,
HOST_DIAGNOSTICS_BYTE_LIMIT,
HOST_DIAGNOSTICS_LINE_LIMIT,
killSidecar,
mergeProxyEnv,
parseH5FixedPort,
@ -51,6 +55,23 @@ function close(server: http.Server): Promise<void> {
}
describe('Electron sidecar manager', () => {
it('places the Electron host log in the active server diagnostics directory', () => {
const portableDir = path.join(tmpdir(), 'cc-haha-portable-diagnostics')
expect(electronHostDiagnosticsFile(
{ CLAUDE_CONFIG_DIR: portableDir },
path.join(tmpdir(), 'unused-home'),
)).toBe(path.join(portableDir, 'cc-haha', 'diagnostics', 'electron-host.log'))
})
it('resolves the default Electron host log without consulting real user state', () => {
const isolatedHome = path.resolve(path.sep, '__cc_haha_injected_test_home__')
expect(electronHostDiagnosticsFile({}, isolatedHome)).toBe(
path.join(isolatedHome, '.claude', 'cc-haha', 'diagnostics', 'electron-host.log'),
)
})
it('maps host platform to existing sidecar target triples', () => {
expect(resolveHostTriple('darwin', 'arm64')).toBe('aarch64-apple-darwin')
expect(resolveHostTriple('darwin', 'x64')).toBe('x86_64-apple-darwin')
@ -160,6 +181,95 @@ describe('Electron sidecar manager', () => {
expect(logs[0]).toBe('line 5')
})
it('sanitizes the bounded startup tail before it reaches an error surface', () => {
const logs: string[] = []
pushStartupLog(
logs,
`Bearer startup.secret sk-proj-STARTUPSECRETVALUE https://alice:password@example.com ${homedir()}/project`,
)
expect(logs[0]).toContain('Bearer [REDACTED]')
expect(logs[0]).toContain('https://[REDACTED]@example.com/')
expect(logs[0]).toContain('[HOME]/project')
expect(logs[0]).not.toContain('startup.secret')
expect(logs[0]).not.toContain('sk-proj-STARTUPSECRETVALUE')
expect(logs[0]).not.toContain(homedir())
})
it('appends only a bounded sanitized Electron host-log tail', () => {
const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-'))
const logPath = path.join(dir, 'electron-host.log')
const homeDir = path.join(dir, 'private-home')
try {
for (let index = 0; index < HOST_DIAGNOSTICS_LINE_LIMIT + 5; index++) {
appendHostDiagnostic(logPath, `line ${index}`, { homeDir })
}
appendHostDiagnostic(
logPath,
`Authorization: Bearer bearer.secret api_key=sk-ant-api03-PRIVATE ANTHROPIC_API_KEY=anthropic-secret OPENAI_API_KEY="openai-secret" MINIMAX_AUTH_TOKEN='minimax-secret' https://alice:password@example.com/private ${homeDir}/project`,
{ homeDir },
)
const contents = readFileSync(logPath, 'utf-8')
const lines = contents.trimEnd().split('\n')
expect(contents).toContain('Bearer [REDACTED]')
expect(contents).toContain('api_key=[REDACTED]')
expect(contents).toContain('ANTHROPIC_API_KEY=[REDACTED]')
expect(contents).toContain('OPENAI_API_KEY=[REDACTED]')
expect(contents).toContain('MINIMAX_AUTH_TOKEN=[REDACTED]')
expect(contents).toContain('https://[REDACTED]@example.com/private')
expect(contents).toContain('[HOME]/project')
expect(contents).not.toContain('bearer.secret')
expect(contents).not.toContain('sk-ant-api03-PRIVATE')
expect(contents).not.toContain('anthropic-secret')
expect(contents).not.toContain('openai-secret')
expect(contents).not.toContain('minimax-secret')
expect(contents).not.toContain('alice:password')
expect(contents).not.toContain(homeDir)
expect(lines).toHaveLength(HOST_DIAGNOSTICS_LINE_LIMIT)
expect(lines[0]).toBe('line 6')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('bounds and re-sanitizes an oversized pre-existing host diagnostics file', () => {
const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-existing-'))
const logPath = path.join(dir, 'electron-host.log')
const homeDir = path.join(dir, 'private-home')
try {
writeFileSync(
logPath,
`${'oversized-old-data '.repeat(HOST_DIAGNOSTICS_BYTE_LIMIT)}\nOPENAI_API_KEY=old-secret ${homeDir}/private\n`,
'utf-8',
)
appendHostDiagnostic(logPath, 'latest safe diagnostic', { homeDir })
const contents = readFileSync(logPath, 'utf-8')
expect(statSync(logPath).size).toBeLessThanOrEqual(HOST_DIAGNOSTICS_BYTE_LIMIT)
expect(contents.trimEnd().split('\n').length).toBeLessThanOrEqual(HOST_DIAGNOSTICS_LINE_LIMIT)
expect(contents).toContain('latest safe diagnostic')
expect(contents).toContain('OPENAI_API_KEY=[REDACTED]')
expect(contents).not.toContain('old-secret')
expect(contents).not.toContain(homeDir)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('does not crash Electron when the host diagnostics destination cannot be written', () => {
const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-failure-'))
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
expect(() => appendHostDiagnostic(dir, 'sidecar failed')).not.toThrow()
expect(errorSpy).toHaveBeenCalledWith('[desktop] failed to persist Electron host diagnostics')
} finally {
errorSpy.mockRestore()
rmSync(dir, { recursive: true, force: true })
}
})
it('maps http urls to adapter websocket urls', () => {
expect(httpToWebSocketUrl('http://127.0.0.1:3456')).toBe('ws://127.0.0.1:3456')
expect(httpToWebSocketUrl('https://example.com')).toBe('wss://example.com')

View File

@ -1,5 +1,16 @@
import { spawn, spawnSync, type ChildProcessByStdio } from 'node:child_process'
import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
import {
closeSync,
existsSync,
fstatSync,
mkdirSync,
openSync,
readFileSync,
readSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs'
import type { Readable } from 'node:stream'
import net from 'node:net'
import os from 'node:os'
@ -10,6 +21,10 @@ export const SERVER_BIND_HOST = '0.0.0.0'
export const SERVER_CONTROL_HOST = '127.0.0.1'
export const SERVER_STARTUP_TIMEOUT_MS = 30_000
export const SERVER_STARTUP_LOG_LIMIT = 80
export const HOST_DIAGNOSTICS_LINE_LIMIT = 80
export const HOST_DIAGNOSTICS_BYTE_LIMIT = 256 * 1024
export const ELECTRON_DIAGNOSTICS_FILE_ENV = 'CC_HAHA_ELECTRON_DIAGNOSTICS_FILE'
const HOST_DIAGNOSTICS_LINE_BYTE_LIMIT = 4096
// Shared with the Tauri shell (src-tauri/src/lib.rs) so both desktop builds
// reuse the same sticky port across restarts (issue #767).
export const SERVER_STATE_FILE = 'desktop-server-state.json'
@ -128,8 +143,18 @@ export async function reserveServerPort(
return await reserveLocalPort(bindHost)
}
export function claudeConfigDir(env: NodeJS.ProcessEnv = process.env): string {
return env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
export function claudeConfigDir(
env: NodeJS.ProcessEnv = process.env,
homeDir = os.homedir(),
): string {
return env.CLAUDE_CONFIG_DIR || path.join(homeDir, '.claude')
}
export function electronHostDiagnosticsFile(
env: NodeJS.ProcessEnv = process.env,
homeDir = os.homedir(),
): string {
return path.join(claudeConfigDir(env, homeDir), 'cc-haha', 'diagnostics', 'electron-host.log')
}
/** Parse h5Access.fixedPort out of cc-haha/settings.json contents. */
@ -238,12 +263,101 @@ function sleep(ms: number): Promise<void> {
}
export function pushStartupLog(logs: string[], line: string) {
const trimmed = line.trimEnd()
const trimmed = sanitizeHostDiagnostic(line, os.homedir())
if (!trimmed) return
if (logs.length >= SERVER_STARTUP_LOG_LIMIT) logs.shift()
logs.push(trimmed)
}
export function appendHostDiagnostic(
filePath: string | undefined,
line: string,
{ homeDir = os.homedir() }: { homeDir?: string } = {},
): void {
if (!filePath) return
const tempPath = `${filePath}.${process.pid}.tmp`
try {
const sanitized = sanitizeHostDiagnostic(line, homeDir)
if (!sanitized) return
const existing = readHostDiagnosticsTail(filePath)
const lines = existing.trimEnd()
? existing.trimEnd().split('\n').map(entry => sanitizeHostDiagnostic(entry, homeDir)).filter(Boolean)
: []
lines.push(sanitized)
const boundedLines: string[] = []
let retainedBytes = 0
for (const entry of lines.slice(-HOST_DIAGNOSTICS_LINE_LIMIT).reverse()) {
const entryBytes = Buffer.byteLength(entry, 'utf-8') + 1
if (retainedBytes + entryBytes > HOST_DIAGNOSTICS_BYTE_LIMIT) break
boundedLines.unshift(entry)
retainedBytes += entryBytes
}
mkdirSync(path.dirname(filePath), { recursive: true })
writeFileSync(tempPath, `${boundedLines.join('\n')}\n`, {
encoding: 'utf-8',
mode: 0o600,
})
renameSync(tempPath, filePath)
} catch {
try {
rmSync(tempPath, { force: true })
} catch {
// Best-effort cleanup must not mask the original diagnostics failure.
}
console.error('[desktop] failed to persist Electron host diagnostics')
}
}
function readHostDiagnosticsTail(filePath: string): string {
let descriptor: number | undefined
try {
descriptor = openSync(filePath, 'r')
const size = fstatSync(descriptor).size
const length = Math.min(size, HOST_DIAGNOSTICS_BYTE_LIMIT)
const buffer = Buffer.alloc(length)
const bytesRead = readSync(descriptor, buffer, 0, length, size - length)
const tail = buffer.subarray(0, bytesRead).toString('utf-8')
if (size <= length) return tail
const firstNewline = tail.indexOf('\n')
return firstNewline >= 0 ? tail.slice(firstNewline + 1) : ''
} catch {
return ''
} finally {
if (descriptor !== undefined) closeSync(descriptor)
}
}
export function sanitizeHostDiagnostic(line: string, homeDir = os.homedir()): string {
let sanitized = line
.replace(/[\r\n]+/g, ' ')
.replace(/https?:\/\/[^\s<>"')\]}]+/gi, candidate => sanitizeUrlUserinfo(candidate))
.replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]')
.replace(
/\b((?:(?:[a-z0-9]+_)*(?:api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?token|password|secret))\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi,
'$1[REDACTED]',
)
.replace(/\b(?:sk-(?:ant-api03-|proj-)?|ghp_)[A-Za-z0-9_-]{8,}\b/g, '[REDACTED]')
.trimEnd()
if (homeDir) sanitized = sanitized.replaceAll(homeDir, '[HOME]')
return truncateUtf8(sanitized, HOST_DIAGNOSTICS_LINE_BYTE_LIMIT)
}
function truncateUtf8(value: string, maxBytes: number): string {
const buffer = Buffer.from(value, 'utf-8')
if (buffer.byteLength <= maxBytes) return value
return buffer.subarray(0, maxBytes).toString('utf-8').replace(/\uFFFD$/, '')
}
function sanitizeUrlUserinfo(candidate: string): string {
try {
const url = new URL(candidate)
if (!url.username && !url.password) return candidate
return `${url.protocol}//[REDACTED]@${url.host}${url.pathname}${url.search}${url.hash}`
} catch {
return '[REDACTED_URL]'
}
}
export function formatStartupError(message: string, logs: string[]): string {
const logText = logs.length > 0
? logs.join('\n')

View File

@ -1,28 +1,64 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Settings } from '../pages/Settings'
import { SAFE_DOCTOR_STORAGE_KEYS } from '../lib/doctorRepair'
import { useSessionStore } from '../stores/sessionStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
const diagnosticsApiMock = vi.hoisted(() => ({
getStatus: vi.fn(),
getEvents: vi.fn(),
getIssueReport: vi.fn(),
exportBundle: vi.fn(),
openLogDir: vi.fn(),
clear: vi.fn(),
}))
const doctorRepairMock = vi.hoisted(() => ({
runDoctorRepair: vi.fn(),
const doctorApiMock = vi.hoisted(() => ({
report: vi.fn(),
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve
reject = nextReject
})
return { promise, resolve, reject }
}
function doctorReport(path: string) {
return {
report: {
generatedAt: '2026-07-11T00:00:00.000Z',
items: [{
id: `finding:${path}`,
label: 'Finding',
kind: 'json' as const,
scope: 'user' as const,
path,
protected: true,
exists: true,
status: 'invalid_schema' as const,
bytes: 42,
}],
protectedSkips: [],
summary: { total: 1, protectedCount: 1, neutralCount: 0, missingCount: 0, invalidCount: 1 },
},
}
}
vi.mock('../api/diagnostics', () => ({
diagnosticsApi: diagnosticsApiMock,
}))
vi.mock('../lib/doctorRepair', () => doctorRepairMock)
vi.mock('../api/doctor', () => ({
doctorApi: doctorApiMock,
}))
vi.mock('../stores/providerStore', () => ({
useProviderStore: () => ({
@ -100,23 +136,35 @@ describe('Settings > Diagnostics tab', () => {
retentionDays: 7,
maxBytes: 50 * 1024 * 1024,
totalBytes: 4096,
eventCount: 2,
eventCount: 600,
physicalLineCount: 601,
corruptLineCount: 1,
storageLimitExceeded: false,
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',
details: {
exitCode: 1,
capturedOutput: 'stderr:\nprovider rejected request',
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',
details: {
exitCode: 1,
capturedOutput: 'stderr:\nprovider rejected request',
},
},
}],
...Array.from({ length: 99 }, (_, index) => ({
id: `event-${index + 2}`,
timestamp: '2026-05-02T00:00:00.000Z',
type: `runtime_event_${index + 2}`,
severity: 'info' as const,
summary: `Runtime event ${index + 2}`,
})),
],
})
diagnosticsApiMock.exportBundle.mockResolvedValue({
bundle: {
@ -125,22 +173,60 @@ describe('Settings > Diagnostics tab', () => {
bytes: 1024,
},
})
diagnosticsApiMock.getIssueReport.mockResolvedValue({
report: '## Diagnostic report\n\n- Event IDs: event-1\n- Private metadata: review before sharing',
})
diagnosticsApiMock.openLogDir.mockResolvedValue({ ok: true })
diagnosticsApiMock.clear.mockResolvedValue({ ok: true })
doctorRepairMock.runDoctorRepair.mockResolvedValue({
local: {
removedKeys: ['cc-haha-open-tabs', 'cc-haha-session-runtime'],
missingKeys: ['cc-haha-theme', 'cc-haha-locale', 'cc-haha.persistence.schemaVersion'],
failedKeys: [],
doctorApiMock.report.mockResolvedValue({
report: {
generatedAt: '2026-07-11T00:00:00.000Z',
items: [
{
id: 'cc-haha-providers',
label: 'Managed providers',
kind: 'json',
scope: 'user',
path: '~/.claude/cc-haha/providers.json',
protected: true,
exists: true,
status: 'invalid_schema',
bytes: 42,
error: 'providers.0.presetId: expected string',
},
{
id: 'project-skills',
label: 'Project skills',
kind: 'directory',
scope: 'project',
path: '<project>/.claude/skills',
protected: true,
exists: true,
status: 'ok',
bytes: 0,
},
],
protectedSkips: [],
summary: { total: 2, protectedCount: 2, neutralCount: 0, missingCount: 0, invalidCount: 1 },
},
server: {
ok: true,
},
serverError: null,
})
useSettingsStore.setState({ locale: 'en' })
useUIStore.setState({ activeSettingsTab: 'providers', pendingSettingsTab: null, toasts: [] })
useSessionStore.setState({
sessions: [{
id: 'session-1',
title: 'Session',
createdAt: '2026-07-11T00:00:00.000Z',
modifiedAt: '2026-07-11T00:00:00.000Z',
messageCount: 0,
projectPath: '/workspace/project',
projectRoot: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
}],
activeSessionId: 'session-1',
})
})
it('shows diagnostics status, actions, and recent events', async () => {
@ -155,6 +241,80 @@ describe('Settings > Diagnostics tab', () => {
expect(screen.getByText('cli_start_failed')).toBeInTheDocument()
expect(screen.getByText('CLI exited during startup with code 1')).toBeInTheDocument()
expect(screen.getByText('Details')).toBeInTheDocument()
expect(screen.getByText('600 complete events')).toBeInTheDocument()
expect(screen.getByText(/evidence of 1 corrupt diagnostic record/)).toBeInTheDocument()
expect(screen.getByText('100 visible events')).toBeInTheDocument()
expect(screen.getAllByText('Event ID:')).toHaveLength(100)
expect(screen.getByText('event-1')).toBeInTheDocument()
expect(screen.getByText(/best-effort/i)).toHaveTextContent(/review.*private metadata/i)
const eventRow = screen.getByText('cli_start_failed').closest('.grid')
expect(eventRow).toHaveClass('grid-cols-1', 'md:grid-cols-[120px_92px_1fr]')
})
it('describes persisted corruption evidence accurately when current logs have no physical lines', async () => {
diagnosticsApiMock.getStatus.mockResolvedValueOnce({
logDir: '/tmp/claude/cc-haha/diagnostics',
diagnosticsPath: '/tmp/claude/cc-haha/diagnostics/diagnostics.jsonl',
cliDiagnosticsPath: '/tmp/claude/cc-haha/diagnostics/cli-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: 0,
eventCount: 0,
physicalLineCount: 0,
corruptLineCount: 2,
storageLimitExceeded: false,
recentErrorCount: 0,
lastEventAt: null,
})
render(<Settings />)
fireEvent.click(screen.getByText('Diagnostics'))
const warning = await screen.findByRole('alert')
expect(warning).toHaveTextContent('Detected or retained evidence of 2 corrupt diagnostic records.')
expect(warning).toHaveTextContent('Current diagnostic files contain 0 physical lines.')
expect(warning).not.toHaveTextContent(/among 0 physical lines/i)
})
it('explains temporary target overflow while active diagnostic segments are still open', async () => {
diagnosticsApiMock.getStatus.mockResolvedValueOnce({
logDir: '/tmp/claude/cc-haha/diagnostics',
diagnosticsPath: '/tmp/claude/cc-haha/diagnostics/diagnostics.jsonl',
cliDiagnosticsPath: '/tmp/claude/cc-haha/diagnostics/cli-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: 52 * 1024 * 1024,
eventCount: 10,
physicalLineCount: 10,
corruptLineCount: 0,
storageLimitExceeded: true,
recentErrorCount: 0,
lastEventAt: '2026-07-11T00:00:00.000Z',
})
render(<Settings />)
fireEvent.click(screen.getByText('Diagnostics'))
const warning = await screen.findByRole('alert')
expect(warning).toHaveTextContent('One or more diagnostic surfaces or active writers temporarily exceed their own retention target.')
expect(warning).toHaveTextContent('Cleanup will occur as their segments close or age out.')
expect(warning).not.toHaveTextContent(/50 MB|strict|hard cap/i)
})
it('marks the active settings tab and its decorative icon accessibly', async () => {
render(<Settings />)
const diagnosticsTab = screen.getByRole('button', { name: 'Diagnostics' })
fireEvent.click(diagnosticsTab)
await screen.findByText('Log directory')
expect(diagnosticsTab).toHaveAttribute('aria-current', 'page')
expect(diagnosticsTab.querySelector('.material-symbols-outlined')).toHaveAttribute('aria-hidden', 'true')
})
it('exports a diagnostics bundle from the settings page', async () => {
@ -239,9 +399,111 @@ describe('Settings > Diagnostics tab', () => {
}
})
it('runs Doctor from Diagnostics without clearing unrelated desktop state', async () => {
window.localStorage.setItem('cc-haha-open-tabs', '{"activeTabId":"__settings__"}')
window.localStorage.setItem('cc-haha-theme', 'dark')
it('copies the share-safe issue Markdown 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')),
},
})
const writeText = vi.mocked(navigator.clipboard.writeText)
try {
render(<Settings />)
fireEvent.click(screen.getByText('Diagnostics'))
fireEvent.click(await screen.findByRole('button', { name: /Copy issue report/i }))
await waitFor(() => {
expect(execCommand).toHaveBeenCalledWith('copy')
})
expect(diagnosticsApiMock.getIssueReport).toHaveBeenCalledTimes(1)
expect(writeText).toHaveBeenCalledWith('## Diagnostic report\n\n- Event IDs: event-1\n- Private metadata: review before sharing')
expect(useUIStore.getState().toasts.at(-1)?.message).toBe('Issue report copied.')
} finally {
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: originalExecCommand,
})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: originalClipboard,
})
}
})
it('copies the exact Event ID and reports success', async () => {
const originalClipboard = navigator.clipboard
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
try {
render(<Settings />)
fireEvent.click(screen.getByText('Diagnostics'))
fireEvent.click(await screen.findByRole('button', { name: 'Copy event ID: event-1' }))
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith('event-1')
})
expect(writeText).toHaveBeenCalledTimes(1)
expect(useUIStore.getState().toasts.at(-1)?.message).toBe('Event ID copied.')
} finally {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: originalClipboard,
})
}
})
it('reports a meaningful error when Event ID copy fails', async () => {
const originalClipboard = navigator.clipboard
const originalExecCommand = document.execCommand
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')) },
})
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: vi.fn().mockReturnValue(false),
})
try {
render(<Settings />)
fireEvent.click(screen.getByText('Diagnostics'))
fireEvent.click(await screen.findByRole('button', { name: 'Copy event ID: event-1' }))
await waitFor(() => {
expect(useUIStore.getState().toasts.at(-1)?.message).toBe('Failed to copy event ID.')
})
} finally {
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: originalExecCommand,
})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: originalClipboard,
})
}
})
it('checks findings first and confirms before resetting only safe desktop state', async () => {
window.localStorage.clear()
for (const key of SAFE_DOCTOR_STORAGE_KEYS) {
window.localStorage.setItem(key, `${key}-value`)
}
window.localStorage.setItem('cc-haha-chat-history', 'keep')
render(<Settings />)
@ -250,11 +512,256 @@ describe('Settings > Diagnostics tab', () => {
fireEvent.click(await screen.findByRole('button', { name: /Run Doctor/i }))
await waitFor(() => {
expect(doctorRepairMock.runDoctorRepair).toHaveBeenCalled()
expect(doctorApiMock.report).toHaveBeenCalledWith('/workspace/project')
})
expect(window.localStorage.getItem('cc-haha-theme')).toBe('cc-haha-theme-value')
expect(screen.getByText('~/.claude/cc-haha/providers.json')).toBeInTheDocument()
expect(screen.getByText(/Invalid schema/i)).toBeInTheDocument()
expect(screen.getByText(/User and active project/i)).toBeInTheDocument()
expect(screen.getByText('Healthy: 1 · Not configured: 0 · Missing: 0 · Invalid: 1')).toBeInTheDocument()
expect(screen.queryByText('<project>/.claude/skills')).not.toBeInTheDocument()
expect(screen.getByText(/cc-haha-app-zoom/)).toBeInTheDocument()
expect(screen.getByText(/cc-haha-ui-zoom/)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Reset safe UI state/i }))
const dialog = await screen.findByRole('dialog', { name: 'Reset safe UI state' })
expect(window.localStorage.getItem('cc-haha-theme')).toBe('cc-haha-theme-value')
fireEvent.click(within(dialog).getByRole('button', { name: /Reset safe UI state/i }))
await waitFor(() => {
expect(doctorApiMock.report).toHaveBeenCalledTimes(2)
})
for (const key of SAFE_DOCTOR_STORAGE_KEYS) {
expect(window.localStorage.getItem(key)).toBeNull()
}
expect(window.localStorage.getItem('cc-haha-chat-history')).toBe('keep')
expect(screen.getByText(/Removed keys:.*cc-haha-app-zoom/)).toBeInTheDocument()
})
it('counts not-configured optional checks separately and excludes them from findings', async () => {
doctorApiMock.report.mockResolvedValueOnce({
report: {
generatedAt: '2026-07-11T00:00:00.000Z',
items: [
{
id: 'user-settings',
label: 'User settings',
kind: 'json' as const,
scope: 'user' as const,
path: '~/.claude/settings.json',
protected: true,
exists: true,
status: 'ok' as const,
bytes: 2,
},
{
id: 'adapters',
label: 'Adapters config',
kind: 'json' as const,
scope: 'user' as const,
path: '~/.claude/adapters.json',
protected: true,
exists: false,
status: 'not_configured' as const,
bytes: 0,
},
{
id: 'cc-haha-providers',
label: 'Managed providers',
kind: 'json' as const,
scope: 'user' as const,
path: '~/.claude/cc-haha/providers.json',
protected: true,
exists: true,
status: 'invalid_schema' as const,
bytes: 10,
},
],
protectedSkips: [],
summary: { total: 3, protectedCount: 3, neutralCount: 1, missingCount: 0, invalidCount: 1 },
},
})
const toasts = useUIStore.getState().toasts
expect(toasts[toasts.length - 1]?.message).toContain('Doctor')
expect(window.localStorage.getItem('cc-haha-chat-history')).toBe('keep')
render(<Settings />)
fireEvent.click(screen.getByText('Diagnostics'))
fireEvent.click(await screen.findByRole('button', { name: /Run Doctor/i }))
expect(await screen.findByText('Healthy: 1 · Not configured: 1 · Missing: 0 · Invalid: 1')).toBeInTheDocument()
expect(screen.getByText('~/.claude/cc-haha/providers.json')).toBeInTheDocument()
expect(screen.queryByText('~/.claude/adapters.json')).not.toBeInTheDocument()
})
it('uses user-only Doctor scope when the active work directory is unavailable', async () => {
useSessionStore.setState((state) => ({
sessions: state.sessions.map((session) => ({ ...session, workDirExists: false })),
}))
render(<Settings />)
fireEvent.click(screen.getByText('Diagnostics'))
expect(await screen.findByText(/User only/i)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Run Doctor/i }))
await waitFor(() => {
expect(doctorApiMock.report).toHaveBeenCalledWith(undefined)
})
})
it('clears an existing Doctor report when the active cwd changes', async () => {
render(<Settings />)
fireEvent.click(screen.getByText('Diagnostics'))
fireEvent.click(await screen.findByRole('button', { name: /Run Doctor/i }))
expect(await screen.findByText('~/.claude/cc-haha/providers.json')).toBeInTheDocument()
await act(async () => {
useSessionStore.setState((state) => ({
sessions: [
...state.sessions,
{
...state.sessions[0]!,
id: 'session-missing-workdir',
workDir: '/workspace/missing',
projectRoot: '/workspace/missing',
workDirExists: false,
},
],
activeSessionId: 'session-missing-workdir',
}))
})
await waitFor(() => {
expect(screen.queryByText('~/.claude/cc-haha/providers.json')).not.toBeInTheDocument()
})
expect(screen.getByText(/User only/i)).toBeInTheDocument()
})
it('keeps newer Doctor loading active when an older response resolves first', async () => {
const oldRequest = deferred<ReturnType<typeof doctorReport>>()
const newRequest = deferred<ReturnType<typeof doctorReport>>()
doctorApiMock.report
.mockReturnValueOnce(oldRequest.promise)
.mockReturnValueOnce(newRequest.promise)
render(<Settings />)
fireEvent.click(screen.getByText('Diagnostics'))
fireEvent.click(await screen.findByRole('button', { name: /Run Doctor/i }))
expect(doctorApiMock.report).toHaveBeenCalledWith('/workspace/project')
await act(async () => {
useSessionStore.setState((state) => ({
sessions: [
...state.sessions,
{
...state.sessions[0]!,
id: 'session-new',
workDir: '/workspace/new',
projectRoot: '/workspace/new',
},
],
activeSessionId: 'session-new',
}))
})
await waitFor(() => {
expect(screen.getByRole('button', { name: /Run Doctor/i })).not.toBeDisabled()
})
fireEvent.click(screen.getByRole('button', { name: /Run Doctor/i }))
expect(doctorApiMock.report).toHaveBeenCalledWith('/workspace/new')
expect(screen.getByRole('button', { name: /Run Doctor/i })).toBeDisabled()
await act(async () => {
oldRequest.resolve(doctorReport('<project>/old-finding.json'))
await Promise.resolve()
})
expect(screen.queryByText('<project>/old-finding.json')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Run Doctor/i })).toBeDisabled()
await act(async () => {
newRequest.resolve(doctorReport('<project>/new-finding.json'))
await Promise.resolve()
})
expect(await screen.findByText('<project>/new-finding.json')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Run Doctor/i })).not.toBeDisabled()
})
it('preserves reset results and releases reset loading when cwd changes during refresh', async () => {
const stalledRefresh = deferred<ReturnType<typeof doctorReport>>()
doctorApiMock.report.mockReturnValueOnce(stalledRefresh.promise)
window.localStorage.clear()
for (const key of SAFE_DOCTOR_STORAGE_KEYS) {
window.localStorage.setItem(key, `${key}-value`)
}
render(<Settings />)
fireEvent.click(screen.getByText('Diagnostics'))
fireEvent.click(await screen.findByRole('button', { name: /Reset safe UI state/i }))
const dialog = await screen.findByRole('dialog', { name: 'Reset safe UI state' })
fireEvent.click(within(dialog).getByRole('button', { name: /Reset safe UI state/i }))
expect(await screen.findByText(/Removed keys:.*cc-haha-app-zoom/)).toBeInTheDocument()
expect(screen.getByText('Failed keys: None')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Reset safe UI state/i })).toBeDisabled()
await act(async () => {
useSessionStore.setState((state) => ({
sessions: [
...state.sessions,
{
...state.sessions[0]!,
id: 'session-reset-new',
workDir: '/workspace/reset-new',
projectRoot: '/workspace/reset-new',
},
],
activeSessionId: 'session-reset-new',
}))
})
expect(screen.getByText(/Removed keys:.*cc-haha-app-zoom/)).toBeInTheDocument()
expect(screen.getByText('Failed keys: None')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Reset safe UI state/i })).not.toBeDisabled()
await act(async () => {
stalledRefresh.resolve(doctorReport('<project>/stale-reset-finding.json'))
await Promise.resolve()
})
expect(screen.queryByText('<project>/stale-reset-finding.json')).not.toBeInTheDocument()
})
it('ignores a stale reset refresh rejection after cwd changes', async () => {
const stalledRefresh = deferred<ReturnType<typeof doctorReport>>()
doctorApiMock.report.mockReturnValueOnce(stalledRefresh.promise)
render(<Settings />)
fireEvent.click(screen.getByText('Diagnostics'))
fireEvent.click(await screen.findByRole('button', { name: /Reset safe UI state/i }))
fireEvent.click(within(await screen.findByRole('dialog', { name: 'Reset safe UI state' }))
.getByRole('button', { name: /Reset safe UI state/i }))
await act(async () => {
useSessionStore.setState((state) => ({
sessions: [
...state.sessions,
{
...state.sessions[0]!,
id: 'session-reject-new',
workDir: '/workspace/reject-new',
projectRoot: '/workspace/reject-new',
},
],
activeSessionId: 'session-reject-new',
}))
})
await act(async () => {
stalledRefresh.reject(new Error('stale reset refresh failed'))
await Promise.resolve()
})
expect(useUIStore.getState().toasts.map((toast) => toast.message)).not.toContain('stale reset refresh failed')
expect(screen.getByRole('button', { name: /Reset safe UI state/i })).not.toBeDisabled()
})
})

View File

@ -30,6 +30,9 @@ export type DiagnosticsStatus = {
maxBytes: number
totalBytes: number
eventCount: number
physicalLineCount: number
corruptLineCount: number
storageLimitExceeded: boolean
recentErrorCount: number
lastEventAt: string | null
}
@ -43,6 +46,7 @@ export type DiagnosticsBundle = {
export const diagnosticsApi = {
getStatus: () => api.get<DiagnosticsStatus>('/api/diagnostics/status'),
getEvents: (limit = 100) => api.get<{ events: DiagnosticEvent[] }>(`/api/diagnostics/events?limit=${limit}`),
getIssueReport: () => api.get<{ report: string }>('/api/diagnostics/issue-report'),
recordEvent: (event: DiagnosticEventInput) => api.post<{ ok: true }>('/api/diagnostics/events', event, { timeout: 5_000 }),
exportBundle: () => api.post<{ bundle: DiagnosticsBundle }>('/api/diagnostics/export', undefined, { timeout: 60_000 }),
openLogDir: () => api.post<{ ok: true }>('/api/diagnostics/open-log-dir'),

View File

@ -8,7 +8,7 @@ export type DoctorReportItem = {
path: string
protected: boolean
exists: boolean
status: 'ok' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'unreadable'
status: 'ok' | 'not_configured' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'invalid_schema' | 'unreadable'
bytes: number
entryCount?: number
lineCount?: number
@ -27,49 +27,15 @@ export type DoctorReport = {
summary: {
total: number
protectedCount: number
neutralCount: number
missingCount: number
invalidCount: number
}
}
export type DoctorRepairResult = {
dryRun: true
mutated: false
operations: Array<{
id: string
path: string
action: 'would_repair'
}>
skips: Array<{
id: string
path: string
reason: 'protected'
}>
summary: {
operationCount: number
skipCount: number
}
}
export type DoctorReportRepairResponse = {
ok: boolean
report: DoctorReport
repair: DoctorRepairResult
}
export const doctorApi = {
report: () => api.get<{ report: DoctorReport }>('/api/doctor/report', { timeout: 3_000 }),
repair: () => api.post<{ result: DoctorRepairResult }>('/api/doctor/repair', {}, { timeout: 3_000 }),
reportAndRepair: async (): Promise<DoctorReportRepairResponse> => {
const [{ report }, { result }] = await Promise.all([
doctorApi.report(),
doctorApi.repair(),
])
return {
ok: true,
report,
repair: result,
}
report: (cwd?: string) => {
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
return api.get<{ report: DoctorReport }>(`/api/doctor/report${query}`, { timeout: 3_000 })
},
}

View File

@ -1,9 +1,16 @@
import { Stethoscope } from 'lucide-react'
import { useState } from 'react'
import { Button } from '../shared/Button'
import { RotateCcw, Stethoscope } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { DoctorReport, DoctorReportItem } from '../../api/doctor'
import { useTranslation } from '../../i18n'
import { runDoctorRepair, type DoctorRepairResult } from '../../lib/doctorRepair'
import {
runDoctorCheck,
runLocalDoctorRepair,
type LocalDoctorRepairResult,
} from '../../lib/doctorRepair'
import { useSessionStore } from '../../stores/sessionStore'
import { useUIStore } from '../../stores/uiStore'
import { Button } from '../shared/Button'
import { ConfirmDialog } from '../shared/ConfirmDialog'
type DoctorPanelProps = {
compact?: boolean
@ -12,32 +19,104 @@ type DoctorPanelProps = {
export function DoctorPanel({ compact = false }: DoctorPanelProps) {
const t = useTranslation()
const addToast = useUIStore((s) => s.addToast)
const [isRunning, setIsRunning] = useState(false)
const [result, setResult] = useState<DoctorRepairResult | null>(null)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const sessions = useSessionStore((s) => s.sessions)
const activeSession = useMemo(
() => sessions.find((session) => session.id === activeSessionId),
[activeSessionId, sessions],
)
const cwd = activeSession?.workDirExists === false
? undefined
: activeSession?.workDir ?? activeSession?.projectRoot ?? undefined
const requestSequence = useRef(0)
const cwdRef = useRef(cwd)
cwdRef.current = cwd
const [runningRequestId, setRunningRequestId] = useState<number | null>(null)
const [resettingRequestId, setResettingRequestId] = useState<number | null>(null)
const [resetConfirmOpen, setResetConfirmOpen] = useState(false)
const [reportResult, setReportResult] = useState<{ cwd?: string; report: DoctorReport } | null>(null)
const [resetResult, setResetResult] = useState<LocalDoctorRepairResult | null>(null)
const report = reportResult && reportResult.cwd === cwd ? reportResult.report : null
useEffect(() => {
requestSequence.current += 1
setRunningRequestId(null)
setResettingRequestId(null)
setReportResult(null)
}, [cwd])
const beginReportRequest = () => {
const requestId = ++requestSequence.current
const requestCwd = cwd
return {
requestId,
requestCwd,
response: runDoctorCheck({ cwd: requestCwd }),
}
}
const isCurrentRequest = (requestId: number, requestCwd?: string) => {
return requestSequence.current === requestId && cwdRef.current === requestCwd
}
const handleRunDoctor = async () => {
setIsRunning(true)
const request = beginReportRequest()
setResettingRequestId(null)
setRunningRequestId(request.requestId)
try {
const nextResult = await runDoctorRepair()
setResult(nextResult)
addToast({
type: nextResult.local.failedKeys.length === 0 ? 'success' : 'warning',
message: getDoctorToastMessage(t, nextResult),
})
const nextReport = await request.response
if (!isCurrentRequest(request.requestId, request.requestCwd)) return
setReportResult({ cwd: request.requestCwd, report: nextReport })
addToast({ type: 'success', message: t('settings.diagnostics.doctorCheckCompleted') })
} catch (error) {
if (!isCurrentRequest(request.requestId, request.requestCwd)) return
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('settings.diagnostics.doctorFailed'),
})
} finally {
setIsRunning(false)
setRunningRequestId((current) => current === request.requestId ? null : current)
}
}
const statusText = result ? getDoctorStatusMessage(t, result) : null
const handleResetSafeState = async () => {
let requestId: number | null = null
const requestCwd = cwd
try {
const result = runLocalDoctorRepair()
setResetResult(result)
setResetConfirmOpen(false)
addToast({
type: result.failedKeys.length === 0 ? 'success' : 'warning',
message: result.failedKeys.length === 0
? t('settings.diagnostics.doctorResetCompleted')
: t('settings.diagnostics.doctorPartial', { count: String(result.failedKeys.length) }),
})
const request = beginReportRequest()
requestId = request.requestId
setRunningRequestId(null)
setResettingRequestId(request.requestId)
const nextReport = await request.response
if (!isCurrentRequest(request.requestId, request.requestCwd)) return
setReportResult({ cwd: request.requestCwd, report: nextReport })
} catch (error) {
if (requestId !== null && !isCurrentRequest(requestId, requestCwd)) return
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('settings.diagnostics.doctorFailed'),
})
} finally {
setResettingRequestId((current) => current === requestId ? null : current)
}
}
const unhealthyItems = report?.items.filter(
(item) => item.status !== 'ok' && item.status !== 'not_configured',
) ?? []
const healthyCount = report?.items.filter((item) => item.status === 'ok').length ?? 0
return (
<section className={`rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] ${compact ? 'p-3' : 'p-4'} `}>
<section className={`rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] ${compact ? 'p-3' : 'p-4'}`}>
<div className={`flex ${compact ? 'flex-col gap-3' : 'items-start justify-between gap-4'}`}>
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">{t('settings.diagnostics.doctorTitle')}</div>
@ -48,59 +127,109 @@ export function DoctorPanel({ compact = false }: DoctorPanelProps) {
{t('settings.diagnostics.doctorProtectedData')}
</p>
</div>
<div className={`flex ${compact ? 'justify-start' : 'justify-end'} shrink-0`}>
<div className={`flex flex-wrap gap-2 ${compact ? 'justify-start' : 'justify-end'} shrink-0`}>
<Button
size="sm"
onClick={handleRunDoctor}
loading={isRunning}
loading={runningRequestId !== null}
icon={<Stethoscope className="h-4 w-4" aria-hidden="true" />}
>
{t('settings.diagnostics.runDoctor')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setResetConfirmOpen(true)}
loading={resettingRequestId !== null}
icon={<RotateCcw className="h-4 w-4" aria-hidden="true" />}
>
{t('settings.diagnostics.resetSafeUiState')}
</Button>
</div>
</div>
<div className="mt-2 text-[11px] leading-relaxed text-[var(--color-text-tertiary)]">
{t('settings.diagnostics.doctorSafeKeys')}
</div>
<div className="mt-2 text-xs text-[var(--color-text-secondary)]">
{t('settings.diagnostics.doctorScope')}: {cwd
? t('settings.diagnostics.doctorScopeProject')
: t('settings.diagnostics.doctorScopeUser')}
</div>
{statusText ? (
<div className="mt-2 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] px-2.5 py-2 text-xs text-[var(--color-text-secondary)]">
{statusText}
{report ? (
<div className="mt-3 space-y-2">
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] px-2.5 py-2 text-xs text-[var(--color-text-secondary)]">
{t('settings.diagnostics.doctorSummary', {
healthy: String(healthyCount),
neutral: String(report.summary.neutralCount),
missing: String(report.summary.missingCount),
invalid: String(report.summary.invalidCount),
})}
</div>
{unhealthyItems.length === 0 ? (
<div className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.diagnostics.doctorNoFindings')}
</div>
) : (
<div className="space-y-1.5" aria-label={t('settings.diagnostics.doctorFindings')}>
{unhealthyItems.map((item) => <DoctorFinding key={item.id} item={item} />)}
</div>
)}
</div>
) : null}
{resetResult ? (
<div className="mt-2 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] px-2.5 py-2 text-xs text-[var(--color-text-secondary)]">
<div>{t('settings.diagnostics.doctorRemovedKeys')}: {formatKeys(resetResult.removedKeys, t('settings.diagnostics.doctorNoKeys'))}</div>
<div className="mt-1">{t('settings.diagnostics.doctorFailedKeys')}: {formatKeys(resetResult.failedKeys, t('settings.diagnostics.doctorNoKeys'))}</div>
</div>
) : null}
<ConfirmDialog
open={resetConfirmOpen}
onClose={() => {
if (resettingRequestId === null) setResetConfirmOpen(false)
}}
onConfirm={handleResetSafeState}
title={t('settings.diagnostics.resetSafeUiState')}
body={t('settings.diagnostics.confirmResetSafeUiState')}
confirmLabel={t('settings.diagnostics.resetSafeUiState')}
cancelLabel={t('common.cancel')}
confirmVariant="danger"
loading={resettingRequestId !== null}
/>
</section>
)
}
function getDoctorToastMessage(
t: ReturnType<typeof useTranslation>,
result: DoctorRepairResult,
): string {
if (result.local.failedKeys.length > 0) {
return t('settings.diagnostics.doctorPartial', { count: String(result.local.failedKeys.length) })
}
return t('settings.diagnostics.doctorCompleted')
function DoctorFinding({ item }: { item: DoctorReportItem }) {
const t = useTranslation()
return (
<div className="rounded-md border border-[var(--color-border)] px-2.5 py-2 text-xs">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-mono text-[var(--color-text-secondary)] break-all">{item.path}</span>
<span className="font-medium text-[var(--color-warning)]">
{getStatusLabel(t, item.status)}
</span>
</div>
{item.error ? <div className="mt-1 text-[var(--color-text-tertiary)] break-words">{item.error}</div> : null}
</div>
)
}
function getDoctorStatusMessage(
t: ReturnType<typeof useTranslation>,
result: DoctorRepairResult,
): string {
const clearedCount = result.local.removedKeys.length
const base = t('settings.diagnostics.doctorResultLocal', { count: String(clearedCount) })
if (result.local.failedKeys.length > 0) {
return `${base} ${t('settings.diagnostics.doctorResultFailedKeys', { count: String(result.local.failedKeys.length) })}`
function getStatusLabel(t: ReturnType<typeof useTranslation>, status: DoctorReportItem['status']): string {
switch (status) {
case 'not_configured': return t('settings.diagnostics.doctorStatusNotConfigured')
case 'missing': return t('settings.diagnostics.doctorStatusMissing')
case 'invalid_json': return t('settings.diagnostics.doctorStatusInvalidJson')
case 'invalid_jsonl': return t('settings.diagnostics.doctorStatusInvalidJsonl')
case 'invalid_schema': return t('settings.diagnostics.doctorStatusInvalidSchema')
case 'unreadable': return t('settings.diagnostics.doctorStatusUnreadable')
default: return t('settings.diagnostics.doctorStatusHealthy')
}
if (result.server) {
return `${base} ${t('settings.diagnostics.doctorServerRan')}`
}
if (result.serverError) {
return `${base} ${t('settings.diagnostics.doctorServerUnavailable')}`
}
return base
}
function formatKeys(keys: string[], emptyLabel: string): string {
return keys.length > 0 ? keys.join(', ') : emptyLabel
}

View File

@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { useSettingsStore } from '../../stores/settingsStore'
import { useUIStore } from '../../stores/uiStore'
import { ToastContainer } from './Toast'
describe('ToastContainer accessibility', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
useUIStore.setState({ toasts: [] })
})
it('announces success and info messages politely as statuses', () => {
useUIStore.setState({
toasts: [
{ id: 'success', type: 'success', message: 'Saved' },
{ id: 'info', type: 'info', message: 'Refreshing' },
],
})
render(<ToastContainer />)
expect(screen.getByText('Saved').closest('[role]')).toHaveAttribute('role', 'status')
expect(screen.getByText('Saved').closest('[role]')).toHaveAttribute('aria-live', 'polite')
expect(screen.getByText('Refreshing').closest('[role]')).toHaveAttribute('role', 'status')
})
it('announces warning and error messages assertively as alerts', () => {
useSettingsStore.setState({ locale: 'zh' })
useUIStore.setState({
toasts: [
{ id: 'warning', type: 'warning', message: 'Check settings' },
{ id: 'error', type: 'error', message: 'Save failed' },
],
})
render(<ToastContainer />)
expect(screen.getByText('Check settings').closest('[role]')).toHaveAttribute('role', 'alert')
expect(screen.getByText('Check settings').closest('[role]')).toHaveAttribute('aria-live', 'assertive')
expect(screen.getByText('Save failed').closest('[role]')).toHaveAttribute('role', 'alert')
expect(screen.getAllByRole('button', { name: '关闭通知' })).toHaveLength(2)
})
})

View File

@ -1,4 +1,5 @@
import { useUIStore, type Toast as ToastType } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
const typeStyles: Record<ToastType['type'], string> = {
success: 'border-l-4 border-l-[var(--color-success)]',
@ -8,10 +9,15 @@ const typeStyles: Record<ToastType['type'], string> = {
}
function ToastItem({ toast }: { toast: ToastType }) {
const t = useTranslation()
const removeToast = useUIStore((s) => s.removeToast)
const isUrgent = toast.type === 'warning' || toast.type === 'error'
return (
<div
role={isUrgent ? 'alert' : 'status'}
aria-live={isUrgent ? 'assertive' : 'polite'}
aria-atomic="true"
className={`
bg-[var(--color-surface)] rounded-[var(--radius-md)] shadow-[var(--shadow-dropdown)]
px-4 py-3 text-sm text-[var(--color-text-primary)]
@ -23,6 +29,7 @@ function ToastItem({ toast }: { toast: ToastType }) {
<span>{toast.message}</span>
<button
onClick={() => removeToast(toast.id)}
aria-label={t('common.dismissNotification')}
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] text-lg leading-none"
>
×

View File

@ -18,6 +18,7 @@ export const en = {
'common.error': 'Error',
'common.copyFailed': 'Copy failed.',
'common.copied': 'Copied',
'common.dismissNotification': 'Dismiss notification',
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': 'New session',
@ -316,6 +317,12 @@ export const en = {
'settings.diagnostics.refresh': 'Refresh',
'settings.diagnostics.totalSize': 'Log size',
'settings.diagnostics.events': 'Events',
'settings.diagnostics.completeEvents': 'Complete events',
'settings.diagnostics.completeEventsValue': '{count} complete events',
'settings.diagnostics.visibleEvents': 'Visible events',
'settings.diagnostics.visibleEventsValue': '{count} visible events',
'settings.diagnostics.corruptLinesWarning': 'Detected or retained evidence of {count} corrupt diagnostic records. Current diagnostic files contain {physical} physical lines. Event totals and recent history may be incomplete.',
'settings.diagnostics.storageLimitExceededWarning': 'One or more diagnostic surfaces or active writers temporarily exceed their own retention target. Cleanup will occur as their segments close or age out.',
'settings.diagnostics.recentErrors': '24h warnings',
'settings.diagnostics.retention': 'Retention',
'settings.diagnostics.retentionValue': '{days} days / {size}',
@ -323,9 +330,12 @@ export const en = {
'settings.diagnostics.openDirectory': 'Open',
'settings.diagnostics.exportBundle': 'Export Bundle',
'settings.diagnostics.copySummary': 'Copy Error Summary',
'settings.diagnostics.copyIssueReport': 'Copy issue report',
'settings.diagnostics.issueReportCopied': 'Issue report copied.',
'settings.diagnostics.issueReportCopyFailed': 'Failed to copy issue report.',
'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.privacyNote': 'Issue reports and exported bundles use best-effort sanitization to omit chat and file contents, full environment variables, and API keys. Review them for private metadata before sharing.',
'settings.diagnostics.noEvents': 'No diagnostic events yet.',
'settings.diagnostics.noRecentErrors': 'No recent warnings or errors.',
'settings.diagnostics.loadFailed': 'Failed to load diagnostics.',
@ -338,18 +348,37 @@ export const en = {
'settings.diagnostics.cleared': 'Diagnostics cleared.',
'settings.diagnostics.clearFailed': 'Failed to clear diagnostics.',
'settings.diagnostics.eventDetails': 'Details',
'settings.diagnostics.eventId': 'Event ID',
'settings.diagnostics.copyEventId': 'Copy event ID',
'settings.diagnostics.eventIdCopied': 'Event ID copied.',
'settings.diagnostics.eventIdCopyFailed': 'Failed to copy event ID.',
'settings.diagnostics.doctorTitle': 'Doctor',
'settings.diagnostics.doctorDescription': 'Safe desktop repair for startup and UI state problems.',
'settings.diagnostics.doctorProtectedData': 'Never touches chat history, model config, skills, MCP, IM, or OAuth.',
'settings.diagnostics.doctorSafeKeys': 'Clears only: cc-haha-open-tabs, cc-haha-session-runtime, cc-haha-theme, cc-haha-locale, and cc-haha.persistence.schemaVersion.',
'settings.diagnostics.doctorDescription': 'Check protected user and project state without changing it.',
'settings.diagnostics.doctorProtectedData': 'Chat history, model config, skills, MCP, IM, and OAuth remain protected.',
'settings.diagnostics.doctorSafeKeys': 'Reset removes only: cc-haha-open-tabs, cc-haha-session-runtime, cc-haha-theme, cc-haha-locale, cc-haha-app-zoom, cc-haha-ui-zoom, and cc-haha.persistence.schemaVersion.',
'settings.diagnostics.runDoctor': 'Run Doctor',
'settings.diagnostics.doctorCompleted': 'Doctor completed.',
'settings.diagnostics.doctorPartial': 'Doctor completed with {count} local cleanup issue(s).',
'settings.diagnostics.resetSafeUiState': 'Reset safe UI state',
'settings.diagnostics.confirmResetSafeUiState': 'Reset only the listed regenerable desktop UI keys? Protected user and project data will not be changed.',
'settings.diagnostics.doctorCheckCompleted': 'Doctor check completed.',
'settings.diagnostics.doctorResetCompleted': 'Safe UI state reset completed.',
'settings.diagnostics.doctorPartial': 'Safe UI state reset completed with {count} failed key(s).',
'settings.diagnostics.doctorFailed': 'Doctor failed.',
'settings.diagnostics.doctorResultLocal': 'Cleared {count} safe UI key(s).',
'settings.diagnostics.doctorResultFailedKeys': '{count} key(s) could not be removed.',
'settings.diagnostics.doctorServerRan': 'Server Doctor repair also ran.',
'settings.diagnostics.doctorServerUnavailable': 'Server Doctor was unavailable, so only local repair ran.',
'settings.diagnostics.doctorScope': 'Scope',
'settings.diagnostics.doctorScopeProject': 'User and active project',
'settings.diagnostics.doctorScopeUser': 'User only',
'settings.diagnostics.doctorSummary': 'Healthy: {healthy} · Not configured: {neutral} · Missing: {missing} · Invalid: {invalid}',
'settings.diagnostics.doctorFindings': 'Doctor findings',
'settings.diagnostics.doctorNoFindings': 'No missing or invalid items found.',
'settings.diagnostics.doctorStatusHealthy': 'Healthy',
'settings.diagnostics.doctorStatusNotConfigured': 'Not configured',
'settings.diagnostics.doctorStatusMissing': 'Missing',
'settings.diagnostics.doctorStatusInvalidJson': 'Invalid JSON',
'settings.diagnostics.doctorStatusInvalidJsonl': 'Invalid JSONL',
'settings.diagnostics.doctorStatusInvalidSchema': 'Invalid schema',
'settings.diagnostics.doctorStatusUnreadable': 'Unreadable',
'settings.diagnostics.doctorRemovedKeys': 'Removed keys',
'settings.diagnostics.doctorFailedKeys': 'Failed keys',
'settings.diagnostics.doctorNoKeys': 'None',
'errorBoundary.title': 'Something went wrong.',
'errorBoundary.description': 'The error was recorded in Diagnostics.',

View File

@ -20,6 +20,7 @@ export const jp: Record<TranslationKey, string> = {
'common.error': 'エラー',
'common.copyFailed': 'コピーに失敗しました。',
'common.copied': 'コピーしました',
'common.dismissNotification': '通知を閉じる',
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '新しいセッション',
@ -318,6 +319,12 @@ export const jp: Record<TranslationKey, string> = {
'settings.diagnostics.refresh': '更新',
'settings.diagnostics.totalSize': 'ログサイズ',
'settings.diagnostics.events': 'イベント',
'settings.diagnostics.completeEvents': '完全なイベント',
'settings.diagnostics.completeEventsValue': '完全なイベント {count} 件',
'settings.diagnostics.visibleEvents': '表示中のイベント',
'settings.diagnostics.visibleEventsValue': '表示中のイベント {count} 件',
'settings.diagnostics.corruptLinesWarning': '{count} 件の破損した診断レコードが検出されたか、その証跡が保持されています。現在の診断ファイルには {physical} 物理行があります。イベント総数と最近の履歴は不完全な可能性があります。',
'settings.diagnostics.storageLimitExceededWarning': '1 つ以上の診断領域または実行中のライターが、それぞれの保持目標を一時的に超えています。各セグメントが閉じるか保持期限を過ぎるとクリーンアップされます。',
'settings.diagnostics.recentErrors': '24 時間の警告',
'settings.diagnostics.retention': '保持期間',
'settings.diagnostics.retentionValue': '{days} 日 / {size}',
@ -325,9 +332,12 @@ export const jp: Record<TranslationKey, string> = {
'settings.diagnostics.openDirectory': '開く',
'settings.diagnostics.exportBundle': 'バンドルをエクスポート',
'settings.diagnostics.copySummary': 'エラー概要をコピー',
'settings.diagnostics.copyIssueReport': 'Issue レポートをコピー',
'settings.diagnostics.issueReportCopied': 'Issue レポートをコピーしました。',
'settings.diagnostics.issueReportCopyFailed': 'Issue レポートをコピーできませんでした。',
'settings.diagnostics.clearLogs': 'ログをクリア',
'settings.diagnostics.recentEvents': '最近のイベント',
'settings.diagnostics.privacyNote': 'エクスポートされる診断情報はサニタイズされており、チャット内容、ファイル内容、すべての環境変数、API キーは含まれません。',
'settings.diagnostics.privacyNote': 'Issue レポートとエクスポートバンドルはベストエフォートでサニタイズされ、チャットやファイルの内容、完全な環境変数、API キーを省略します。共有前に非公開メタデータがないか確認してください。',
'settings.diagnostics.noEvents': '診断イベントはまだありません。',
'settings.diagnostics.noRecentErrors': '最近の警告やエラーはありません。',
'settings.diagnostics.loadFailed': '診断情報の読み込みに失敗しました。',
@ -340,18 +350,37 @@ export const jp: Record<TranslationKey, string> = {
'settings.diagnostics.cleared': '診断情報をクリアしました。',
'settings.diagnostics.clearFailed': '診断情報のクリアに失敗しました。',
'settings.diagnostics.eventDetails': '詳細',
'settings.diagnostics.eventId': 'イベント ID',
'settings.diagnostics.copyEventId': 'イベント ID をコピー',
'settings.diagnostics.eventIdCopied': 'イベント ID をコピーしました。',
'settings.diagnostics.eventIdCopyFailed': 'イベント ID をコピーできませんでした。',
'settings.diagnostics.doctorTitle': 'Doctor',
'settings.diagnostics.doctorDescription': '起動および UI 状態の問題に対する安全なデスクトップ修復。',
'settings.diagnostics.doctorProtectedData': 'チャット履歴、モデル設定、スキル、MCP、IM、OAuth には一切影響しません。',
'settings.diagnostics.doctorSafeKeys': 'クリアされるのは次のみ: cc-haha-open-tabs、cc-haha-session-runtime、cc-haha-theme、cc-haha-locale、cc-haha.persistence.schemaVersion。',
'settings.diagnostics.doctorDescription': '保護されたユーザーとプロジェクトの状態を変更せずに確認します。',
'settings.diagnostics.doctorProtectedData': 'チャット履歴、モデル設定、スキル、MCP、IM、OAuth は常に保護されます。',
'settings.diagnostics.doctorSafeKeys': 'リセットで削除されるのは次のみ: cc-haha-open-tabs、cc-haha-session-runtime、cc-haha-theme、cc-haha-locale、cc-haha-app-zoom、cc-haha-ui-zoom、cc-haha.persistence.schemaVersion。',
'settings.diagnostics.runDoctor': 'Doctor を実行',
'settings.diagnostics.doctorCompleted': 'Doctor が完了しました。',
'settings.diagnostics.doctorPartial': 'Doctor は完了しましたが、{count} 件のローカルクリーンアップの問題があります。',
'settings.diagnostics.resetSafeUiState': '安全な UI 状態をリセット',
'settings.diagnostics.confirmResetSafeUiState': '上記の再生成可能なデスクトップ UI キーだけをリセットしますか?保護されたユーザーとプロジェクトのデータは変更されません。',
'settings.diagnostics.doctorCheckCompleted': 'Doctor のチェックが完了しました。',
'settings.diagnostics.doctorResetCompleted': '安全な UI 状態のリセットが完了しました。',
'settings.diagnostics.doctorPartial': '安全な UI 状態のリセットは完了しましたが、{count} 件のキーで失敗しました。',
'settings.diagnostics.doctorFailed': 'Doctor が失敗しました。',
'settings.diagnostics.doctorResultLocal': '{count} 件の安全な UI キーをクリアしました。',
'settings.diagnostics.doctorResultFailedKeys': '{count} 件のキーを削除できませんでした。',
'settings.diagnostics.doctorServerRan': 'サーバー側の Doctor 修復も実行されました。',
'settings.diagnostics.doctorServerUnavailable': 'サーバー側の Doctor が利用できなかったため、ローカル修復のみ実行されました。',
'settings.diagnostics.doctorScope': '範囲',
'settings.diagnostics.doctorScopeProject': 'ユーザーとアクティブなプロジェクト',
'settings.diagnostics.doctorScopeUser': 'ユーザーのみ',
'settings.diagnostics.doctorSummary': '正常: {healthy} · 未設定: {neutral} · 不足: {missing} · 無効: {invalid}',
'settings.diagnostics.doctorFindings': 'Doctor の検出結果',
'settings.diagnostics.doctorNoFindings': '不足または無効な項目はありません。',
'settings.diagnostics.doctorStatusHealthy': '正常',
'settings.diagnostics.doctorStatusNotConfigured': '未設定',
'settings.diagnostics.doctorStatusMissing': '不足',
'settings.diagnostics.doctorStatusInvalidJson': '無効な JSON',
'settings.diagnostics.doctorStatusInvalidJsonl': '無効な JSONL',
'settings.diagnostics.doctorStatusInvalidSchema': '無効なスキーマ',
'settings.diagnostics.doctorStatusUnreadable': '読み取り不可',
'settings.diagnostics.doctorRemovedKeys': '削除したキー',
'settings.diagnostics.doctorFailedKeys': '失敗したキー',
'settings.diagnostics.doctorNoKeys': 'なし',
'errorBoundary.title': '問題が発生しました。',
'errorBoundary.description': 'エラーは診断に記録されました。',

View File

@ -20,6 +20,7 @@ export const kr: Record<TranslationKey, string> = {
'common.error': '오류',
'common.copyFailed': '복사하지 못했습니다.',
'common.copied': '복사됨',
'common.dismissNotification': '알림 닫기',
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '새 세션',
@ -318,6 +319,12 @@ export const kr: Record<TranslationKey, string> = {
'settings.diagnostics.refresh': '새로 고침',
'settings.diagnostics.totalSize': '로그 크기',
'settings.diagnostics.events': '이벤트',
'settings.diagnostics.completeEvents': '정상 이벤트',
'settings.diagnostics.completeEventsValue': '정상 이벤트 {count}개',
'settings.diagnostics.visibleEvents': '표시된 이벤트',
'settings.diagnostics.visibleEventsValue': '표시된 이벤트 {count}개',
'settings.diagnostics.corruptLinesWarning': '손상된 진단 레코드 {count}개의 증거가 감지되었거나 보존되어 있습니다. 현재 진단 파일에는 물리적 줄 {physical}개가 있습니다. 이벤트 합계와 최근 기록이 불완전할 수 있습니다.',
'settings.diagnostics.storageLimitExceededWarning': '하나 이상의 진단 영역 또는 활성 작성 프로세스가 각자의 보존 목표를 일시적으로 초과했습니다. 해당 세그먼트가 닫히거나 보존 기간이 지나면 정리됩니다.',
'settings.diagnostics.recentErrors': '24시간 경고',
'settings.diagnostics.retention': '보존 기간',
'settings.diagnostics.retentionValue': '{days}일 / {size}',
@ -325,9 +332,12 @@ export const kr: Record<TranslationKey, string> = {
'settings.diagnostics.openDirectory': '열기',
'settings.diagnostics.exportBundle': '번들 내보내기',
'settings.diagnostics.copySummary': '오류 요약 복사',
'settings.diagnostics.copyIssueReport': '이슈 보고서 복사',
'settings.diagnostics.issueReportCopied': '이슈 보고서를 복사했습니다.',
'settings.diagnostics.issueReportCopyFailed': '이슈 보고서를 복사하지 못했습니다.',
'settings.diagnostics.clearLogs': '로그 지우기',
'settings.diagnostics.recentEvents': '최근 이벤트',
'settings.diagnostics.privacyNote': '내보낸 진단 정보는 정제되며 채팅 내용, 파일 내용, 전체 환경 변수, API 키는 포함하지 않습니다.',
'settings.diagnostics.privacyNote': '이슈 보고서와 내보낸 번들은 최선의 방식으로 정리되어 채팅 및 파일 내용, 전체 환경 변수와 API 키를 생략합니다. 공유 전에 비공개 메타데이터가 없는지 검토하세요.',
'settings.diagnostics.noEvents': '아직 진단 이벤트가 없습니다.',
'settings.diagnostics.noRecentErrors': '최근 경고나 오류가 없습니다.',
'settings.diagnostics.loadFailed': '진단 정보를 불러오지 못했습니다.',
@ -340,18 +350,37 @@ export const kr: Record<TranslationKey, string> = {
'settings.diagnostics.cleared': '진단 정보를 지웠습니다.',
'settings.diagnostics.clearFailed': '진단 정보를 지우지 못했습니다.',
'settings.diagnostics.eventDetails': '세부 정보',
'settings.diagnostics.eventId': '이벤트 ID',
'settings.diagnostics.copyEventId': '이벤트 ID 복사',
'settings.diagnostics.eventIdCopied': '이벤트 ID를 복사했습니다.',
'settings.diagnostics.eventIdCopyFailed': '이벤트 ID를 복사하지 못했습니다.',
'settings.diagnostics.doctorTitle': 'Doctor',
'settings.diagnostics.doctorDescription': '시작 및 UI 상태 문제를 위한 안전한 데스크톱 복구입니다.',
'settings.diagnostics.doctorProtectedData': '채팅 기록, 모델 구성, 스킬, MCP, IM, OAuth는 절대 건드리지 않습니다.',
'settings.diagnostics.doctorSafeKeys': '다음만 지웁니다: cc-haha-open-tabs, cc-haha-session-runtime, cc-haha-theme, cc-haha-locale, cc-haha.persistence.schemaVersion.',
'settings.diagnostics.doctorDescription': '보호된 사용자 및 프로젝트 상태를 변경하지 않고 확인합니다.',
'settings.diagnostics.doctorProtectedData': '채팅 기록, 모델 구성, 스킬, MCP, IM, OAuth는 항상 보호됩니다.',
'settings.diagnostics.doctorSafeKeys': '재설정 시 다음 항목만 제거합니다: cc-haha-open-tabs, cc-haha-session-runtime, cc-haha-theme, cc-haha-locale, cc-haha-app-zoom, cc-haha-ui-zoom, cc-haha.persistence.schemaVersion.',
'settings.diagnostics.runDoctor': 'Doctor 실행',
'settings.diagnostics.doctorCompleted': 'Doctor가 완료되었습니다.',
'settings.diagnostics.doctorPartial': 'Doctor가 완료되었지만 {count}건의 로컬 정리 문제가 있습니다.',
'settings.diagnostics.resetSafeUiState': '안전한 UI 상태 재설정',
'settings.diagnostics.confirmResetSafeUiState': '위에 나열된 재생성 가능한 데스크톱 UI 키만 재설정할까요? 보호된 사용자 및 프로젝트 데이터는 변경되지 않습니다.',
'settings.diagnostics.doctorCheckCompleted': 'Doctor 확인이 완료되었습니다.',
'settings.diagnostics.doctorResetCompleted': '안전한 UI 상태 재설정이 완료되었습니다.',
'settings.diagnostics.doctorPartial': '안전한 UI 상태 재설정이 완료되었지만 {count}개의 키를 처리하지 못했습니다.',
'settings.diagnostics.doctorFailed': 'Doctor가 실패했습니다.',
'settings.diagnostics.doctorResultLocal': '{count}개의 안전한 UI 키를 지웠습니다.',
'settings.diagnostics.doctorResultFailedKeys': '{count}개의 키를 제거하지 못했습니다.',
'settings.diagnostics.doctorServerRan': '서버 측 Doctor 복구도 실행되었습니다.',
'settings.diagnostics.doctorServerUnavailable': '서버 측 Doctor를 사용할 수 없어 로컬 복구만 실행되었습니다.',
'settings.diagnostics.doctorScope': '범위',
'settings.diagnostics.doctorScopeProject': '사용자 및 활성 프로젝트',
'settings.diagnostics.doctorScopeUser': '사용자만',
'settings.diagnostics.doctorSummary': '정상: {healthy} · 구성 안 됨: {neutral} · 누락: {missing} · 유효하지 않음: {invalid}',
'settings.diagnostics.doctorFindings': 'Doctor 발견 항목',
'settings.diagnostics.doctorNoFindings': '누락되거나 유효하지 않은 항목이 없습니다.',
'settings.diagnostics.doctorStatusHealthy': '정상',
'settings.diagnostics.doctorStatusNotConfigured': '구성 안 됨',
'settings.diagnostics.doctorStatusMissing': '누락',
'settings.diagnostics.doctorStatusInvalidJson': '유효하지 않은 JSON',
'settings.diagnostics.doctorStatusInvalidJsonl': '유효하지 않은 JSONL',
'settings.diagnostics.doctorStatusInvalidSchema': '유효하지 않은 스키마',
'settings.diagnostics.doctorStatusUnreadable': '읽을 수 없음',
'settings.diagnostics.doctorRemovedKeys': '제거된 키',
'settings.diagnostics.doctorFailedKeys': '실패한 키',
'settings.diagnostics.doctorNoKeys': '없음',
'errorBoundary.title': '문제가 발생했습니다.',
'errorBoundary.description': '오류가 진단에 기록되었습니다.',

View File

@ -20,6 +20,7 @@ export const zh: Record<TranslationKey, string> = {
'common.error': '錯誤',
'common.copyFailed': '複製失敗。',
'common.copied': '已複製',
'common.dismissNotification': '關閉通知',
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '新建會話',
@ -318,6 +319,12 @@ export const zh: Record<TranslationKey, string> = {
'settings.diagnostics.refresh': '重新整理',
'settings.diagnostics.totalSize': '日誌大小',
'settings.diagnostics.events': '事件數',
'settings.diagnostics.completeEvents': '完整事件',
'settings.diagnostics.completeEventsValue': '{count} 個完整事件',
'settings.diagnostics.visibleEvents': '目前可見',
'settings.diagnostics.visibleEventsValue': '{count} 個可見事件',
'settings.diagnostics.corruptLinesWarning': '偵測到或保留了 {count} 筆損壞診斷記錄的證據。目前診斷檔案包含 {physical} 個實體行,事件總數與最近記錄可能不完整。',
'settings.diagnostics.storageLimitExceededWarning': '一個或多個診斷區域或活動寫入程序暫時超出各自的保留目標;其分段關閉或過期後將進行清理。',
'settings.diagnostics.recentErrors': '24h 警告',
'settings.diagnostics.retention': '保留策略',
'settings.diagnostics.retentionValue': '{days} 天 / {size}',
@ -325,9 +332,12 @@ export const zh: Record<TranslationKey, string> = {
'settings.diagnostics.openDirectory': '開啟',
'settings.diagnostics.exportBundle': '匯出診斷包',
'settings.diagnostics.copySummary': '複製錯誤摘要',
'settings.diagnostics.copyIssueReport': '複製 Issue 報告',
'settings.diagnostics.issueReportCopied': 'Issue 報告已複製。',
'settings.diagnostics.issueReportCopyFailed': '複製 Issue 報告失敗。',
'settings.diagnostics.clearLogs': '清理日誌',
'settings.diagnostics.recentEvents': '最近事件',
'settings.diagnostics.privacyNote': '匯出的診斷包會脫敏,不包含聊天內容、檔案內容、完整環境變數或 API Key。',
'settings.diagnostics.privacyNote': 'Issue 報告與匯出套件會盡力遮蔽,省略聊天與檔案內容、完整環境變數及 API Key分享前仍請檢查是否含有私密中繼資料。',
'settings.diagnostics.noEvents': '暫無診斷事件。',
'settings.diagnostics.noRecentErrors': '最近沒有警告或錯誤。',
'settings.diagnostics.loadFailed': '載入診斷資訊失敗。',
@ -340,18 +350,37 @@ export const zh: Record<TranslationKey, string> = {
'settings.diagnostics.cleared': '診斷日誌已清理。',
'settings.diagnostics.clearFailed': '清理診斷日誌失敗。',
'settings.diagnostics.eventDetails': '詳情',
'settings.diagnostics.eventId': '事件 ID',
'settings.diagnostics.copyEventId': '複製事件 ID',
'settings.diagnostics.eventIdCopied': '事件 ID 已複製。',
'settings.diagnostics.eventIdCopyFailed': '複製事件 ID 失敗。',
'settings.diagnostics.doctorTitle': 'Doctor',
'settings.diagnostics.doctorDescription': '用於啟動和介面狀態問題的安全桌面修復。',
'settings.diagnostics.doctorProtectedData': '絕不會觸碰聊天曆史、模型配置、Skills、MCP、IM 或 OAuth。',
'settings.diagnostics.doctorSafeKeys': '只會清理cc-haha-open-tabs、cc-haha-session-runtime、cc-haha-theme、cc-haha-locale 和 cc-haha.persistence.schemaVersion。',
'settings.diagnostics.doctorDescription': '檢查受保護的使用者與專案狀態,不會進行修改。',
'settings.diagnostics.doctorProtectedData': '聊天歷史、模型配置、Skills、MCP、IM 和 OAuth 始終受保護。',
'settings.diagnostics.doctorSafeKeys': '重設只會移除cc-haha-open-tabs、cc-haha-session-runtime、cc-haha-theme、cc-haha-locale、cc-haha-app-zoom、cc-haha-ui-zoom 和 cc-haha.persistence.schemaVersion。',
'settings.diagnostics.runDoctor': '執行 Doctor',
'settings.diagnostics.doctorCompleted': 'Doctor 已執行完成。',
'settings.diagnostics.doctorPartial': 'Doctor 已完成,但有 {count} 個本地清理項處理失敗。',
'settings.diagnostics.resetSafeUiState': '重設安全 UI 狀態',
'settings.diagnostics.confirmResetSafeUiState': '僅重設上方列出的可再生桌面 UI 鍵?受保護的使用者與專案資料不會被修改。',
'settings.diagnostics.doctorCheckCompleted': 'Doctor 檢查已完成。',
'settings.diagnostics.doctorResetCompleted': '安全 UI 狀態重設已完成。',
'settings.diagnostics.doctorPartial': '安全 UI 狀態重設已完成,但有 {count} 個鍵處理失敗。',
'settings.diagnostics.doctorFailed': 'Doctor 執行失敗。',
'settings.diagnostics.doctorResultLocal': '已清理 {count} 個安全 UI 鍵。',
'settings.diagnostics.doctorResultFailedKeys': '有 {count} 個鍵無法移除。',
'settings.diagnostics.doctorServerRan': '服務端 Doctor 修復也已執行。',
'settings.diagnostics.doctorServerUnavailable': '服務端 Doctor 不可用,因此只執行了本地修復。',
'settings.diagnostics.doctorScope': '範圍',
'settings.diagnostics.doctorScopeProject': '使用者與目前專案',
'settings.diagnostics.doctorScopeUser': '僅使用者',
'settings.diagnostics.doctorSummary': '健康:{healthy} · 未設定:{neutral} · 缺少:{missing} · 無效:{invalid}',
'settings.diagnostics.doctorFindings': 'Doctor 發現項目',
'settings.diagnostics.doctorNoFindings': '未發現缺少或無效項目。',
'settings.diagnostics.doctorStatusHealthy': '健康',
'settings.diagnostics.doctorStatusNotConfigured': '未設定',
'settings.diagnostics.doctorStatusMissing': '缺少',
'settings.diagnostics.doctorStatusInvalidJson': 'JSON 無效',
'settings.diagnostics.doctorStatusInvalidJsonl': 'JSONL 無效',
'settings.diagnostics.doctorStatusInvalidSchema': 'Schema 無效',
'settings.diagnostics.doctorStatusUnreadable': '無法讀取',
'settings.diagnostics.doctorRemovedKeys': '已移除的鍵',
'settings.diagnostics.doctorFailedKeys': '失敗的鍵',
'settings.diagnostics.doctorNoKeys': '無',
'errorBoundary.title': '出現異常。',
'errorBoundary.description': '錯誤已記錄到診斷日誌。',

View File

@ -20,6 +20,7 @@ export const zh: Record<TranslationKey, string> = {
'common.error': '错误',
'common.copyFailed': '复制失败。',
'common.copied': '已复制',
'common.dismissNotification': '关闭通知',
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '新建会话',
@ -318,6 +319,12 @@ export const zh: Record<TranslationKey, string> = {
'settings.diagnostics.refresh': '刷新',
'settings.diagnostics.totalSize': '日志大小',
'settings.diagnostics.events': '事件数',
'settings.diagnostics.completeEvents': '完整事件',
'settings.diagnostics.completeEventsValue': '{count} 个完整事件',
'settings.diagnostics.visibleEvents': '当前可见',
'settings.diagnostics.visibleEventsValue': '{count} 个可见事件',
'settings.diagnostics.corruptLinesWarning': '检测到或保留了 {count} 条损坏诊断记录的证据。当前诊断文件包含 {physical} 个物理行,事件总数和最近记录可能不完整。',
'settings.diagnostics.storageLimitExceededWarning': '一个或多个诊断区域或活动写入进程暂时超出各自的保留目标;其分段关闭或过期后将进行清理。',
'settings.diagnostics.recentErrors': '24h 警告',
'settings.diagnostics.retention': '保留策略',
'settings.diagnostics.retentionValue': '{days} 天 / {size}',
@ -325,9 +332,12 @@ export const zh: Record<TranslationKey, string> = {
'settings.diagnostics.openDirectory': '打开',
'settings.diagnostics.exportBundle': '导出诊断包',
'settings.diagnostics.copySummary': '复制错误摘要',
'settings.diagnostics.copyIssueReport': '复制 Issue 报告',
'settings.diagnostics.issueReportCopied': 'Issue 报告已复制。',
'settings.diagnostics.issueReportCopyFailed': '复制 Issue 报告失败。',
'settings.diagnostics.clearLogs': '清理日志',
'settings.diagnostics.recentEvents': '最近事件',
'settings.diagnostics.privacyNote': '导出的诊断包会脱敏,不包含聊天内容、文件内容、完整环境变量或 API Key。',
'settings.diagnostics.privacyNote': 'Issue 报告和导出包会尽力脱敏,省略聊天与文件内容、完整环境变量和 API Key分享前仍请检查是否含有私密元数据。',
'settings.diagnostics.noEvents': '暂无诊断事件。',
'settings.diagnostics.noRecentErrors': '最近没有警告或错误。',
'settings.diagnostics.loadFailed': '加载诊断信息失败。',
@ -340,18 +350,37 @@ export const zh: Record<TranslationKey, string> = {
'settings.diagnostics.cleared': '诊断日志已清理。',
'settings.diagnostics.clearFailed': '清理诊断日志失败。',
'settings.diagnostics.eventDetails': '详情',
'settings.diagnostics.eventId': '事件 ID',
'settings.diagnostics.copyEventId': '复制事件 ID',
'settings.diagnostics.eventIdCopied': '事件 ID 已复制。',
'settings.diagnostics.eventIdCopyFailed': '复制事件 ID 失败。',
'settings.diagnostics.doctorTitle': 'Doctor',
'settings.diagnostics.doctorDescription': '用于启动和界面状态问题的安全桌面修复。',
'settings.diagnostics.doctorProtectedData': '绝不会触碰聊天历史、模型配置、Skills、MCP、IM 或 OAuth。',
'settings.diagnostics.doctorSafeKeys': '只会清理cc-haha-open-tabs、cc-haha-session-runtime、cc-haha-theme、cc-haha-locale 和 cc-haha.persistence.schemaVersion。',
'settings.diagnostics.doctorDescription': '检查受保护的用户与项目状态,不会进行修改。',
'settings.diagnostics.doctorProtectedData': '聊天历史、模型配置、Skills、MCP、IM 和 OAuth 始终受保护。',
'settings.diagnostics.doctorSafeKeys': '重置只会移除cc-haha-open-tabs、cc-haha-session-runtime、cc-haha-theme、cc-haha-locale、cc-haha-app-zoom、cc-haha-ui-zoom 和 cc-haha.persistence.schemaVersion。',
'settings.diagnostics.runDoctor': '运行 Doctor',
'settings.diagnostics.doctorCompleted': 'Doctor 已执行完成。',
'settings.diagnostics.doctorPartial': 'Doctor 已完成,但有 {count} 个本地清理项处理失败。',
'settings.diagnostics.resetSafeUiState': '重置安全 UI 状态',
'settings.diagnostics.confirmResetSafeUiState': '仅重置上面列出的可再生桌面 UI 键?受保护的用户与项目数据不会被修改。',
'settings.diagnostics.doctorCheckCompleted': 'Doctor 检查已完成。',
'settings.diagnostics.doctorResetCompleted': '安全 UI 状态重置已完成。',
'settings.diagnostics.doctorPartial': '安全 UI 状态重置已完成,但有 {count} 个键处理失败。',
'settings.diagnostics.doctorFailed': 'Doctor 执行失败。',
'settings.diagnostics.doctorResultLocal': '已清理 {count} 个安全 UI 键。',
'settings.diagnostics.doctorResultFailedKeys': '有 {count} 个键无法移除。',
'settings.diagnostics.doctorServerRan': '服务端 Doctor 修复也已执行。',
'settings.diagnostics.doctorServerUnavailable': '服务端 Doctor 不可用,因此只执行了本地修复。',
'settings.diagnostics.doctorScope': '范围',
'settings.diagnostics.doctorScopeProject': '用户与当前项目',
'settings.diagnostics.doctorScopeUser': '仅用户',
'settings.diagnostics.doctorSummary': '健康:{healthy} · 未配置:{neutral} · 缺失:{missing} · 无效:{invalid}',
'settings.diagnostics.doctorFindings': 'Doctor 发现项',
'settings.diagnostics.doctorNoFindings': '未发现缺失或无效项。',
'settings.diagnostics.doctorStatusHealthy': '健康',
'settings.diagnostics.doctorStatusNotConfigured': '未配置',
'settings.diagnostics.doctorStatusMissing': '缺失',
'settings.diagnostics.doctorStatusInvalidJson': 'JSON 无效',
'settings.diagnostics.doctorStatusInvalidJsonl': 'JSONL 无效',
'settings.diagnostics.doctorStatusInvalidSchema': 'Schema 无效',
'settings.diagnostics.doctorStatusUnreadable': '无法读取',
'settings.diagnostics.doctorRemovedKeys': '已移除的键',
'settings.diagnostics.doctorFailedKeys': '失败的键',
'settings.diagnostics.doctorNoKeys': '无',
'errorBoundary.title': '出现异常。',
'errorBoundary.description': '错误已记录到诊断日志。',

View File

@ -1,14 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const doctorApiMock = vi.hoisted(() => ({
reportAndRepair: vi.fn(),
report: vi.fn(),
}))
vi.mock('../api/doctor', () => ({
doctorApi: doctorApiMock,
}))
import { SAFE_DOCTOR_STORAGE_KEYS, runLocalDoctorRepair, runDoctorRepair } from './doctorRepair'
import { SAFE_DOCTOR_STORAGE_KEYS, runDoctorCheck, runLocalDoctorRepair } from './doctorRepair'
describe('doctorRepair', () => {
beforeEach(() => {
@ -50,16 +50,22 @@ describe('doctorRepair', () => {
expect(result.failedKeys).toEqual(expect.arrayContaining([...SAFE_DOCTOR_STORAGE_KEYS]))
})
it('keeps local repair successful when the server doctor endpoint is unavailable', async () => {
it('checks the server report for the active cwd without clearing desktop state', async () => {
window.localStorage.clear()
window.localStorage.setItem('cc-haha-theme', 'dark')
doctorApiMock.reportAndRepair.mockRejectedValueOnce(new Error('Failed to fetch'))
doctorApiMock.report.mockResolvedValueOnce({
report: {
generatedAt: '2026-07-11T00:00:00.000Z',
items: [],
protectedSkips: [],
summary: { total: 0, protectedCount: 0, missingCount: 0, invalidCount: 0 },
},
})
const result = await runDoctorRepair({ storage: window.localStorage })
const report = await runDoctorCheck({ cwd: '/workspace/project' })
expect(doctorApiMock.reportAndRepair).toHaveBeenCalled()
expect(result.local.removedKeys).toContain('cc-haha-theme')
expect(result.server).toBeNull()
expect(result.serverError).toBe('Failed to fetch')
expect(doctorApiMock.report).toHaveBeenCalledWith('/workspace/project')
expect(report.summary.total).toBe(0)
expect(window.localStorage.getItem('cc-haha-theme')).toBe('dark')
})
})

View File

@ -1,4 +1,4 @@
import { doctorApi, type DoctorReportRepairResponse } from '../api/doctor'
import { doctorApi, type DoctorReport } from '../api/doctor'
import { APP_ZOOM_STORAGE_KEY, LEGACY_UI_ZOOM_STORAGE_KEY } from './appZoom'
import { DESKTOP_PERSISTENCE_VERSION_KEY } from './persistenceMigrations'
@ -20,12 +20,6 @@ export type LocalDoctorRepairResult = {
failedKeys: string[]
}
export type DoctorRepairResult = {
local: LocalDoctorRepairResult
server: DoctorReportRepairResponse | null
serverError: string | null
}
function getDefaultDoctorStorage(): DoctorStorage | null {
try {
return globalThis.localStorage ?? null
@ -63,23 +57,7 @@ export function runLocalDoctorRepair(storage: DoctorStorage | null = getDefaultD
return { removedKeys, missingKeys, failedKeys }
}
export async function runDoctorRepair(options?: {
includeServer?: boolean
storage?: DoctorStorage | null
}): Promise<DoctorRepairResult> {
const local = runLocalDoctorRepair(options?.storage)
if (options?.includeServer === false) {
return { local, server: null, serverError: null }
}
try {
const server = await doctorApi.reportAndRepair()
return { local, server, serverError: null }
} catch (error) {
return {
local,
server: null,
serverError: error instanceof Error ? error.message : String(error),
}
}
export async function runDoctorCheck(options: { cwd?: string } = {}): Promise<DoctorReport> {
const { report } = await doctorApi.report(options.cwd)
return report
}

View File

@ -15,6 +15,7 @@ export function DiagnosticsSettings() {
const [events, setEvents] = useState<DiagnosticEvent[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isExporting, setIsExporting] = useState(false)
const [isCopyingIssueReport, setIsCopyingIssueReport] = useState(false)
const [isClearing, setIsClearing] = useState(false)
const [clearConfirmOpen, setClearConfirmOpen] = useState(false)
const [lastExportPath, setLastExportPath] = useState<string | null>(null)
@ -91,6 +92,27 @@ export function DiagnosticsSettings() {
addToast({ type: 'error', message: t('settings.diagnostics.copyFailed') })
}
const handleCopyIssueReport = async () => {
setIsCopyingIssueReport(true)
try {
const { report } = await diagnosticsApi.getIssueReport()
const copied = await copyTextToClipboard(report)
addToast({
type: copied ? 'success' : 'error',
message: copied
? t('settings.diagnostics.issueReportCopied')
: t('settings.diagnostics.issueReportCopyFailed'),
})
} catch (error) {
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('settings.diagnostics.issueReportCopyFailed'),
})
} finally {
setIsCopyingIssueReport(false)
}
}
const handleClear = async () => {
setIsClearing(true)
try {
@ -112,24 +134,40 @@ export function DiagnosticsSettings() {
return (
<div className="max-w-4xl">
<div className="flex items-start justify-between gap-4 mb-5">
<div className="flex flex-col sm:flex-row sm:items-start sm: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>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">refresh</span>
{t('settings.diagnostics.refresh')}
</Button>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 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.completeEvents')} value={status ? t('settings.diagnostics.completeEventsValue', { count: status.eventCount }) : '-'} />
<Metric label={t('settings.diagnostics.visibleEvents')} value={t('settings.diagnostics.visibleEventsValue', { count: events.length })} />
<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>
{status && status.corruptLineCount > 0 ? (
<div role="alert" className="mb-5 rounded-lg border border-[var(--color-warning)]/40 bg-[var(--color-warning)]/10 px-3 py-2 text-xs text-[var(--color-warning)]">
{t('settings.diagnostics.corruptLinesWarning', {
count: status.corruptLineCount,
physical: status.physicalLineCount,
})}
</div>
) : null}
{status?.storageLimitExceeded ? (
<div role="alert" className="mb-5 rounded-lg border border-[var(--color-warning)]/40 bg-[var(--color-warning)]/10 px-3 py-2 text-xs text-[var(--color-warning)]">
{t('settings.diagnostics.storageLimitExceededWarning')}
</div>
) : null}
<div className="mb-5">
<DoctorPanel />
</div>
@ -141,25 +179,29 @@ export function DiagnosticsSettings() {
<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>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">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>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">archive</span>
{t('settings.diagnostics.exportBundle')}
</Button>
<Button variant="secondary" size="sm" onClick={handleCopySummary}>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">content_copy</span>
{t('settings.diagnostics.copySummary')}
</Button>
<Button variant="secondary" size="sm" onClick={handleCopyIssueReport} loading={isCopyingIssueReport}>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">assignment</span>
{t('settings.diagnostics.copyIssueReport')}
</Button>
<Button variant="danger" size="sm" onClick={() => setClearConfirmOpen(true)} loading={isClearing}>
<span className="material-symbols-outlined text-[16px]">delete</span>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">delete</span>
{t('settings.diagnostics.clearLogs')}
</Button>
{lastExportPath && (
<span className="text-xs text-[var(--color-text-tertiary)] font-mono break-all">
<span className="w-full text-xs text-[var(--color-text-tertiary)] font-mono break-all">
{lastExportPath}
</span>
)}
@ -183,6 +225,11 @@ export function DiagnosticsSettings() {
key={event.id}
event={event}
detailsLabel={t('settings.diagnostics.eventDetails')}
eventIdLabel={t('settings.diagnostics.eventId')}
copyEventIdLabel={t('settings.diagnostics.copyEventId')}
eventIdCopiedLabel={t('settings.diagnostics.eventIdCopied')}
eventIdCopyFailedLabel={t('settings.diagnostics.eventIdCopyFailed')}
addToast={addToast}
/>
))}
</div>
@ -218,9 +265,19 @@ function Metric({ label, value }: { label: string; value: string }) {
function EventRow({
event,
detailsLabel,
eventIdLabel,
copyEventIdLabel,
eventIdCopiedLabel,
eventIdCopyFailedLabel,
addToast,
}: {
event: DiagnosticEvent
detailsLabel: string
eventIdLabel: string
copyEventIdLabel: string
eventIdCopiedLabel: string
eventIdCopyFailedLabel: string
addToast: ReturnType<typeof useUIStore.getState>['addToast']
}) {
const severityClass =
event.severity === 'error'
@ -231,7 +288,7 @@ function EventRow({
const detailsText = formatDetails(event.details)
return (
<div className="px-4 py-3 grid grid-cols-[120px_92px_1fr] gap-3 items-start">
<div className="px-4 py-3 grid grid-cols-1 md: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>
@ -244,6 +301,19 @@ function EventRow({
)}
</div>
<div className="text-xs text-[var(--color-text-secondary)] mt-1 break-words">{event.summary}</div>
<button
type="button"
className="mt-1 inline-flex max-w-full items-center gap-1 text-[11px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]"
aria-label={`${copyEventIdLabel}: ${event.id}`}
onClick={async () => {
const copied = await copyTextToClipboard(event.id)
addToast({ type: copied ? 'success' : 'error', message: copied ? eventIdCopiedLabel : eventIdCopyFailedLabel })
}}
>
<span>{eventIdLabel}:</span>
<span className="font-mono truncate">{event.id}</span>
<span className="material-symbols-outlined text-[13px]" aria-hidden="true">content_copy</span>
</button>
{detailsText && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-[var(--color-text-tertiary)] select-none">

View File

@ -261,13 +261,14 @@ function TabButton({ icon, label, active, onClick }: { icon: string; label: stri
return (
<button
onClick={onClick}
aria-current={active ? 'page' : undefined}
className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-left transition-colors ${
active
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] font-medium'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="material-symbols-outlined text-[18px]">{icon}</span>
<span className="material-symbols-outlined text-[18px]" aria-hidden="true">{icon}</span>
{label}
</button>
)

View File

@ -1052,6 +1052,35 @@ describe('ConversationService', () => {
expect(completionObserved).toBe(true)
expect(service.hasSession(sessionId)).toBe(false)
})
test('summarizes SDK diagnostics with transport metadata only', () => {
const service = new ConversationService()
const summarized = (service as any).summarizeSdkMessages([{
type: 'assistant',
subtype: 'api_error',
is_error: true,
status: 'failed',
result: 'PRIVATE_SDK_RESULT',
error: 'PRIVATE_SDK_ERROR',
errorDetails: 'PRIVATE_ERROR_DETAILS',
message: {
content: [{ type: 'text', text: 'PRIVATE_ASSISTANT_REPLY' }],
},
}])
expect(summarized).toEqual([{
type: 'assistant',
subtype: 'api_error',
is_error: true,
status: 'failed',
errorCategory: 'api_error',
}])
const serialized = JSON.stringify(summarized)
expect(serialized).not.toContain('PRIVATE_SDK_RESULT')
expect(serialized).not.toContain('PRIVATE_SDK_ERROR')
expect(serialized).not.toContain('PRIVATE_ERROR_DETAILS')
expect(serialized).not.toContain('PRIVATE_ASSISTANT_REPLY')
})
})
function sanitizeMemoryPath(value: string): string {

View File

@ -62,6 +62,61 @@ async function waitForHttp(url: string, timeoutMs: number): Promise<void> {
throw new Error(`Timed out waiting for ${url}${lastError ? ` (${lastError})` : ''}`)
}
function readTarEntry(archive: Buffer, entryName: string): string {
const tar = gunzipSync(archive)
let offset = 0
while (offset + 512 <= tar.length) {
const header = tar.subarray(offset, offset + 512)
if (header.every((byte) => byte === 0)) break
const name = header.subarray(0, 100).toString('utf-8').replace(/\0.*$/, '')
const sizeText = header.subarray(124, 136).toString('ascii').replace(/\0.*$/, '').trim()
const size = Number.parseInt(sizeText || '0', 8)
const contentStart = offset + 512
if (name === entryName) {
return tar.subarray(contentStart, contentStart + size).toString('utf-8')
}
offset = contentStart + Math.ceil(size / 512) * 512
}
throw new Error(`Missing tar entry: ${entryName}`)
}
async function assertAppendSurvivesCliCleanup(
service: DiagnosticsService,
appendTarget: string,
marker: string,
): Promise<void> {
await fs.mkdir(service.getLogDir(), { recursive: true })
await fs.writeFile(appendTarget, 'old cli event\n')
const staleDate = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)
await fs.utimes(appendTarget, staleDate, staleDate)
const originalRename = fs.rename
const originalRm = fs.rm
let injected = false
const injectAppend = async () => {
if (injected) return
injected = true
await fs.appendFile(appendTarget, `${marker}\n`)
}
const renameSpy = spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (from === appendTarget) await injectAppend()
return originalRename(from, to)
})
const rmSpy = spyOn(fs, 'rm').mockImplementation(async (target, options) => {
if (target === appendTarget) await injectAppend()
return originalRm(target, options)
})
try {
await service.getStatus()
} finally {
renameSpy.mockRestore()
rmSpy.mockRestore()
}
expect(injected).toBe(true)
const bundle = await service.exportBundle()
expect(readTarEntry(await fs.readFile(bundle.path), 'cli-diagnostics.jsonl')).toContain(marker)
}
describe('DiagnosticsService', () => {
test('writes sanitized structured events and runtime error summaries', async () => {
const service = new DiagnosticsService()
@ -123,6 +178,154 @@ describe('DiagnosticsService', () => {
}
})
test('counts all retained events and reports corrupt lines outside the visible window', async () => {
const service = new DiagnosticsService()
const events = Array.from({ length: 600 }, (_, index) => ({
id: String(index),
timestamp: new Date().toISOString(),
type: index < 100 ? 'real_failure' : 'normal_exit',
severity: index < 100 ? 'error' : 'info',
summary: index < 100 ? 'boom' : 'clean',
}))
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
await fs.writeFile(service.getDiagnosticsPath(), `${events.map(JSON.stringify).join('\n')}\n{broken`)
const status = await service.getStatus()
expect(status.eventCount).toBe(600)
expect(status.recentErrorCount).toBe(100)
expect(status.physicalLineCount).toBe(601)
expect(status.corruptLineCount).toBe(1)
expect(await service.readRecentEvents(100)).toHaveLength(100)
})
test('counts blank and invalid event-shaped lines as corrupt physical lines', async () => {
const service = new DiagnosticsService()
const validEvent = {
id: 'valid',
timestamp: new Date().toISOString(),
type: 'valid_event',
severity: 'info',
summary: 'valid',
}
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
await fs.writeFile(service.getDiagnosticsPath(), [
JSON.stringify(validEvent),
'',
'null',
'[]',
JSON.stringify({ ...validEvent, severity: 'fatal' }),
JSON.stringify({ ...validEvent, timestamp: 'not-a-date' }),
JSON.stringify({ ...validEvent, summary: 42 }),
'',
].join('\n'))
const status = await service.getStatus()
expect(status.eventCount).toBe(1)
expect(status.physicalLineCount).toBe(7)
expect(status.corruptLineCount).toBe(6)
expect((await service.readRecentEvents()).map((event) => event.id)).toEqual(['valid'])
})
test('preserves corrupt-line evidence across the next retention rewrite', async () => {
const service = new DiagnosticsService()
const staleEvent = {
id: 'stale-before-corruption',
timestamp: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(),
type: 'stale',
severity: 'info',
summary: 'old',
}
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(staleEvent)}\n{PRIVATE_CORRUPT_PAYLOAD\n`)
await service.recordEvent({ type: 'fresh_after_corruption', summary: 'fresh' })
const firstStatus = await service.getStatus()
expect(firstStatus.corruptLineCount).toBe(1)
expect((await service.readRecentEvents()).map((event) => event.type)).toEqual(['fresh_after_corruption'])
expect(await fs.readFile(service.getDiagnosticsPath(), 'utf-8')).not.toContain('PRIVATE_CORRUPT_PAYLOAD')
await service.recordEvent({ type: 'second_fresh', summary: 'second' })
expect((await service.getStatus()).corruptLineCount).toBe(1)
})
test('does not double count corrupt evidence when the structured rewrite fails and retries', async () => {
const service = new DiagnosticsService()
const validEvent = {
id: 'valid-before-retry',
timestamp: new Date().toISOString(),
type: 'valid',
severity: 'info',
summary: 'valid',
}
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(validEvent)}\n{broken-once\n`)
const originalRename = fs.rename
let failedRewrite = false
const renameSpy = spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (!failedRewrite && to === service.getDiagnosticsPath()) {
failedRewrite = true
throw Object.assign(new Error('forced diagnostics rewrite failure'), { code: 'EIO' })
}
return originalRename(from, to)
})
try {
await service.recordEvent({ type: 'first_retry_probe', summary: 'first' })
expect((await service.getStatus()).corruptLineCount).toBe(1)
await service.recordEvent({ type: 'second_retry_probe', summary: 'second' })
} finally {
renameSpy.mockRestore()
}
expect((await service.getStatus()).corruptLineCount).toBe(1)
expect((await service.readRecentEvents()).map((event) => event.type)).toContain('second_retry_probe')
})
test('recovers pending corrupt evidence after rewrite succeeds but commit fails, then counts new corruption once', async () => {
const service = new DiagnosticsService()
const validEvent = {
id: 'valid-before-commit-crash',
timestamp: new Date().toISOString(),
type: 'valid',
severity: 'info',
summary: 'valid',
}
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(validEvent)}\n{first-corrupt\n`)
const evidencePath = path.join(service.getLogDir(), 'corruption-evidence.json')
const originalRename = fs.rename
let failedCommit = false
const renameSpy = spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (!failedCommit && to === evidencePath) {
failedCommit = true
throw Object.assign(new Error('forced evidence commit failure'), { code: 'EIO' })
}
return originalRename(from, to)
})
try {
await service.recordEvent({ type: 'rewrite_succeeds_commit_fails', summary: 'first' })
await fs.appendFile(service.getDiagnosticsPath(), '{second-corrupt\n')
await service.recordEvent({ type: 'retry_after_new_corruption', summary: 'second' })
} finally {
renameSpy.mockRestore()
}
expect((await service.getStatus()).corruptLineCount).toBe(2)
expect((await service.readRecentEvents()).map((event) => event.type)).toContain('retry_after_new_corruption')
})
test('returns a failed write result when the diagnostics directory cannot be created', async () => {
const service = new DiagnosticsService()
const blockedConfigDir = path.join(tmpDir, 'config-file')
await fs.writeFile(blockedConfigDir, 'not-a-directory')
process.env.CLAUDE_CONFIG_DIR = blockedConfigDir
const result = await service.recordEvent({ type: 'write_probe', severity: 'error', summary: 'boom' })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error).not.toContain(tmpDir)
})
test('exports a single diagnostics tarball without provider secrets', async () => {
const service = new DiagnosticsService()
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
@ -135,7 +338,7 @@ describe('DiagnosticsService', () => {
name: 'Test Provider',
presetId: 'custom',
apiKey: 'sk-provider-secret',
baseUrl: 'https://api.example.com/anthropic',
baseUrl: 'https://user:pass@api.example.com/private?token=x',
apiFormat: 'anthropic',
models: { main: 'main-model', haiku: 'haiku-model', sonnet: 'sonnet-model', opus: 'opus-model' },
}],
@ -166,10 +369,384 @@ describe('DiagnosticsService', () => {
expect(archiveText).toContain('cli_streaming_idle_timeout')
expect(archiveText).toContain('Test Provider')
expect(archiveText).toContain('api.example.com')
expect(archiveText).toContain('projected diagnostic metadata/details')
expect(archiveText).not.toContain('captured runtime details')
expect(archiveText).not.toContain('user:pass')
expect(archiveText).not.toContain('/private')
expect(archiveText).not.toContain('token=x')
expect(archiveText).not.toContain('sk-provider-secret')
expect(archiveText).not.toContain('provider-secret')
})
test('leaves exported runtime-errors.log empty when only info events exist', async () => {
const service = new DiagnosticsService()
await service.recordEvent({
type: 'informational_probe',
severity: 'info',
summary: 'routine status',
})
const bundle = await service.exportBundle()
expect(readTarEntry(await fs.readFile(bundle.path), 'runtime-errors.log')).toBe('')
})
test('exports share projections without captured SDK or assistant content', async () => {
const service = new DiagnosticsService()
await service.recordEvent({
type: 'sdk_api_error',
severity: 'error',
summary: 'PRIVATE_ASSISTANT_REPLY',
details: {
errorCode: 'API_ERROR',
status: 'failed',
capturedOutput: 'PRIVATE_CAPTURED_OUTPUT',
sdkMessages: [{ result: 'PRIVATE_SDK_RESULT' }],
bareToken: 'sk-ant-api03-BARESECRET',
},
})
const bundle = await service.exportBundle()
const archiveText = gunzipSync(await fs.readFile(bundle.path)).toString('utf-8')
expect(archiveText).toContain('event')
expect(archiveText).toContain('omittedFields')
expect(archiveText).toContain('API_ERROR')
expect(archiveText).not.toContain('PRIVATE_ASSISTANT_REPLY')
expect(archiveText).not.toContain('PRIVATE_CAPTURED_OUTPUT')
expect(archiveText).not.toContain('PRIVATE_SDK_RESULT')
expect(archiveText).not.toContain('sk-ant-api03-BARESECRET')
})
test('bounds shared event payloads and issue-report event ids to recent evidence', async () => {
const service = new DiagnosticsService()
const events = Array.from({ length: 5_100 }, (_, index) => ({
id: `event-${index}`,
timestamp: new Date(Date.now() + index).toISOString(),
type: 'bounded_export_probe',
severity: 'error',
summary: `private summary ${index}`,
}))
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
await fs.writeFile(service.getDiagnosticsPath(), `${events.map(JSON.stringify).join('\n')}\n`)
const bundle = await service.exportBundle()
const exportedEvents = readTarEntry(await fs.readFile(bundle.path), 'diagnostics.jsonl')
.trim().split('\n').filter(Boolean)
expect(exportedEvents).toHaveLength(5_000)
expect(exportedEvents[0]).toContain('event-5099')
expect(exportedEvents.at(-1)).toContain('event-100')
expect(exportedEvents.join('\n')).not.toContain('event-99"')
const report = await service.buildIssueReport()
expect(report).toContain('event-5099')
expect(report).not.toContain('event-4999')
const eventIdsLine = report.split('\n').find((line) => line.startsWith('- Event IDs:')) ?? ''
expect(eventIdsLine.length).toBeLessThan(8_000)
})
test('includes a sanitized bounded Electron host log in status and exports', async () => {
const service = new DiagnosticsService()
const electronHostPath = path.join(service.getLogDir(), 'electron-host.log')
await fs.mkdir(service.getLogDir(), { recursive: true })
await fs.writeFile(
electronHostPath,
`${'old host line\n'.repeat(500_000)}latest failure token=ELECTRON_SECRET /Users/alice/private/project\n`,
)
const status = await service.getStatus()
expect(status.electronHostPath).toBe(electronHostPath)
expect((await fs.stat(electronHostPath)).size).toBeLessThanOrEqual(5 * 1024 * 1024)
const bundle = await service.exportBundle()
const hostLog = readTarEntry(await fs.readFile(bundle.path), 'electron-host.log')
expect(hostLog).toContain('latest failure')
expect(hostLog).not.toContain('ELECTRON_SECRET')
expect(hostLog).not.toContain('/Users/alice')
expect(Buffer.byteLength(hostLog)).toBeLessThanOrEqual(256 * 1024 + 64)
})
test('bounds active diagnostic surfaces and recent exports under the advertised directory cap', async () => {
const service = new DiagnosticsService()
await fs.mkdir(service.getExportDir(), { recursive: true })
const oversized = `${'diagnostic line\n'.repeat(450_000)}latest marker\n`
const closedCliSegment = `${service.getCliDiagnosticsPath()}.123.1.jsonl`
await Promise.all([
fs.writeFile(service.getRuntimeErrorsPath(), oversized),
fs.writeFile(closedCliSegment, oversized),
fs.writeFile(path.join(service.getLogDir(), 'electron-host.log'), oversized),
...Array.from({ length: 20 }, (_, index) => fs.writeFile(
path.join(service.getExportDir(), `recent-${String(index).padStart(2, '0')}.tar.gz`),
Buffer.alloc(1024 * 1024, index),
)),
])
const status = await service.getStatus()
expect(status.totalBytes).toBeLessThanOrEqual(status.maxBytes)
for (const filePath of [
service.getRuntimeErrorsPath(),
path.join(service.getLogDir(), 'electron-host.log'),
]) {
expect((await fs.stat(filePath)).size).toBeLessThanOrEqual(5 * 1024 * 1024)
}
await expect(fs.stat(closedCliSegment)).rejects.toThrow()
const exports = await fs.readdir(service.getExportDir())
const exportStats = await Promise.all(exports.map((name) => fs.stat(path.join(service.getExportDir(), name))))
expect(exportStats.reduce((sum, stat) => sum + stat.size, 0)).toBeLessThanOrEqual(15 * 1024 * 1024)
})
test('caps closed CLI segments globally and reports active multiprocess overflow honestly', async () => {
const service = new DiagnosticsService()
await fs.mkdir(service.getLogDir(), { recursive: true })
await Promise.all([
...Array.from({ length: 8 }, (_, index) => fs.writeFile(
`${service.getCliDiagnosticsPath()}.${index}.closed.jsonl`,
Buffer.alloc(1024 * 1024, index),
)),
...Array.from({ length: 55 }, (_, index) => fs.writeFile(
`${service.getCliDiagnosticsPath()}.unknown-${index}.current.jsonl`,
Buffer.alloc(1024 * 1024, index),
)),
])
const status = await service.getStatus()
const files = await fs.readdir(service.getLogDir())
const closed = files.filter((name) => name.includes('.closed.jsonl'))
const closedStats = await Promise.all(closed.map((name) => fs.stat(path.join(service.getLogDir(), name))))
expect(closedStats.reduce((sum, stat) => sum + stat.size, 0)).toBeLessThanOrEqual(5 * 1024 * 1024)
expect(status.totalBytes).toBeGreaterThan(status.maxBytes)
expect(status.storageLimitExceeded).toBe(true)
})
test('reclaims dead current CLI segments but preserves the live current process segment', async () => {
const service = new DiagnosticsService()
await fs.mkdir(service.getLogDir(), { recursive: true })
const livePath = `${service.getCliDiagnosticsPath()}.${process.pid}.current.jsonl`
const deadPath = `${service.getCliDiagnosticsPath()}.99999999.current.jsonl`
await fs.writeFile(livePath, 'live-current\n')
await fs.writeFile(deadPath, 'dead-current\n')
await service.getStatus()
await expect(fs.readFile(livePath, 'utf-8')).resolves.toContain('live-current')
await expect(fs.stat(deadPath)).rejects.toThrow()
const staleDate = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)
await fs.utimes(livePath, staleDate, staleDate)
await service.getStatus()
await expect(fs.stat(livePath)).rejects.toThrow()
})
test('reclaims stale legacy and unknown-PID current files by retention lease while preserving recent legacy', async () => {
const service = new DiagnosticsService()
await fs.mkdir(service.getLogDir(), { recursive: true })
const legacyPath = service.getCliDiagnosticsPath()
const unknownCurrentPath = `${service.getCliDiagnosticsPath()}.not-a-pid.current.jsonl`
await fs.writeFile(legacyPath, 'stale legacy\n')
await fs.writeFile(unknownCurrentPath, 'stale unknown pid\n')
const staleDate = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)
await fs.utimes(legacyPath, staleDate, staleDate)
await fs.utimes(unknownCurrentPath, staleDate, staleDate)
await service.getStatus()
await expect(fs.stat(legacyPath)).rejects.toThrow()
await expect(fs.stat(unknownCurrentPath)).rejects.toThrow()
await fs.writeFile(legacyPath, Buffer.alloc(6 * 1024 * 1024, 1))
const recentLegacyStatus = await service.getStatus()
await expect(fs.stat(legacyPath)).resolves.toBeTruthy()
expect(recentLegacyStatus.storageLimitExceeded).toBe(true)
await fs.utimes(legacyPath, staleDate, staleDate)
const reclaimedStatus = await service.getStatus()
await expect(fs.stat(legacyPath)).rejects.toThrow()
expect(reclaimedStatus.storageLimitExceeded).toBe(true)
const settledStatus = await service.getStatus()
expect(settledStatus.storageLimitExceeded).toBe(false)
})
test('never replaces an active legacy CLI diagnostics file during retention', async () => {
const service = new DiagnosticsService()
const cliPath = service.getCliDiagnosticsPath()
await fs.mkdir(path.dirname(cliPath), { recursive: true })
await fs.writeFile(cliPath, `${'old cli line\n'.repeat(500_000)}before-retention\n`)
const originalRename = fs.rename
let cliRenameAttempted = false
const renameSpy = spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (to === cliPath) cliRenameAttempted = true
return originalRename(from, to)
})
try {
await Promise.all([
service.getStatus(),
fs.appendFile(cliPath, 'append-during-retention\n'),
])
} finally {
renameSpy.mockRestore()
}
expect(cliRenameAttempted).toBe(false)
expect(await fs.readFile(cliPath, 'utf-8')).toContain('append-during-retention')
})
test('re-lists CLI segments when the writer rotates between export listing and reading', async () => {
const service = new DiagnosticsService()
await fs.mkdir(service.getLogDir(), { recursive: true })
const currentPath = `${service.getCliDiagnosticsPath()}.${process.pid}.current.jsonl`
const completedPath = `${service.getCliDiagnosticsPath()}.${process.pid}.rotated.jsonl`
await fs.writeFile(currentPath, '{"event":"ROTATED_BETWEEN_LIST_AND_READ"}\n')
const originalOpen = fs.open
let rotated = false
const openSpy = spyOn(fs, 'open').mockImplementation(async (filePath, ...args) => {
if (!rotated && filePath === currentPath) {
rotated = true
await fs.rename(currentPath, completedPath)
}
return originalOpen(filePath, ...args)
})
try {
const bundle = await service.exportBundle()
const cliLog = readTarEntry(await fs.readFile(bundle.path), 'cli-diagnostics.jsonl')
expect(cliLog).toContain('ROTATED_BETWEEN_LIST_AND_READ')
} finally {
openSpy.mockRestore()
}
})
test('quarantines a stale PID-current segment so an append immediately before cleanup survives', async () => {
const service = new DiagnosticsService()
const currentPath = `${service.getCliDiagnosticsPath()}.${process.pid}.current.jsonl`
await assertAppendSurvivesCliCleanup(service, currentPath, 'PID_CURRENT_NEW_EVENT')
})
test('quarantines a stale legacy append target so an append immediately before cleanup survives', async () => {
const service = new DiagnosticsService()
await assertAppendSurvivesCliCleanup(service, service.getCliDiagnosticsPath(), 'LEGACY_NEW_EVENT')
})
test('compacts stale structured events without deleting the active diagnostics file', async () => {
const service = new DiagnosticsService()
const staleEvent = {
id: 'stale',
timestamp: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(),
type: 'stale',
severity: 'info',
summary: 'old',
}
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(staleEvent)}\n`)
await service.recordEvent({ type: 'fresh', severity: 'info', summary: 'new' })
expect((await service.readRecentEvents()).map((event) => event.type)).toEqual(['fresh'])
await expect(fs.stat(service.getDiagnosticsPath())).resolves.toBeTruthy()
})
test('keeps the newest complete structured lines when the active file exceeds the byte cap', async () => {
const service = new DiagnosticsService()
const oversizedSummary = 'x'.repeat(51 * 1024 * 1024)
const oversizedEvent = {
id: 'oversized',
timestamp: new Date().toISOString(),
type: 'oversized',
severity: 'info',
summary: oversizedSummary,
}
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(oversizedEvent)}\n`)
await service.recordEvent({ type: 'fresh', severity: 'info', summary: 'new' })
expect((await service.readRecentEvents()).map((event) => event.type)).toEqual(['fresh'])
await expect(fs.stat(service.getDiagnosticsPath())).resolves.toBeTruthy()
})
test('serializes concurrent writes with forced compaction so neither event is lost', async () => {
const service = new DiagnosticsService()
const oversizedEvent = {
id: 'oversized',
timestamp: new Date().toISOString(),
type: 'oversized',
severity: 'info',
summary: 'x'.repeat(51 * 1024 * 1024),
}
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(oversizedEvent)}\n`)
let releaseFirstRename!: () => void
let firstRenameReached!: () => void
const firstRenameGate = new Promise<void>((resolve) => { releaseFirstRename = resolve })
const firstRenameSignal = new Promise<void>((resolve) => { firstRenameReached = resolve })
const originalRename = fs.rename
let renameCount = 0
const renameSpy = spyOn(fs, 'rename').mockImplementation(async (...args) => {
renameCount += 1
if (renameCount === 1) {
firstRenameReached()
await firstRenameGate
return originalRename(...args)
}
return originalRename(...args)
})
try {
const firstWrite = service.recordEvent({ type: 'concurrent_first', summary: 'first' })
await firstRenameSignal
const secondWrite = service.recordEvent({ type: 'concurrent_second', summary: 'second' })
await Bun.sleep(100)
expect(renameCount).toBe(1)
releaseFirstRename()
await Promise.all([firstWrite, secondWrite])
} finally {
releaseFirstRename()
renameSpy.mockRestore()
}
expect((await service.readRecentEvents()).map((event) => event.type)).toEqual([
'concurrent_second',
'concurrent_first',
])
})
test('exports the tail of oversized CLI text logs', async () => {
const service = new DiagnosticsService()
const cliPath = service.getCliDiagnosticsPath()
await fs.mkdir(path.dirname(cliPath), { recursive: true })
await fs.writeFile(
cliPath,
`OLDEST_PREFIX_MARKER\n${'routine diagnostic line\n'.repeat(15_000)}LATEST_FAILURE_MARKER\n`,
)
const bundle = await service.exportBundle()
const archiveText = gunzipSync(await fs.readFile(bundle.path)).toString('utf-8')
expect(archiveText).toContain('[TRUNCATED OLDER CONTENT]')
expect(archiveText).toContain('LATEST_FAILURE_MARKER')
expect(archiveText).not.toContain('OLDEST_PREFIX_MARKER')
})
test('redacts a secret whose key is split across the tail-read byte boundary', async () => {
const service = new DiagnosticsService()
const cliPath = service.getCliDiagnosticsPath()
const secret = 'BOUNDARY_SPLIT_SECRET'
const marker = 'LATEST_FAILURE_MARKER\n'
const suffixWithoutKey = `${secret}\n\n${marker}`
const paddingBytes = 256 * 1024 - Buffer.byteLength(suffixWithoutKey)
const safeLine = 'safe line\n'
const suffixPadding = safeLine.repeat(Math.floor(paddingBytes / Buffer.byteLength(safeLine))) +
'z'.repeat(paddingBytes % Buffer.byteLength(safeLine))
await fs.mkdir(path.dirname(cliPath), { recursive: true })
await fs.writeFile(
cliPath,
`${'old-prefix'.repeat(600)}\ntoken=${secret}\n${suffixPadding}\n${marker}`,
)
const bundle = await service.exportBundle()
const archiveText = gunzipSync(await fs.readFile(bundle.path)).toString('utf-8')
expect(archiveText).toContain('LATEST_FAILURE_MARKER')
expect(archiveText).not.toContain(secret)
})
test('keeps fatal startup errors visible on stderr while recording diagnostics', async () => {
const port = await getPort()
const serverArgs = ['bun', '--no-env-file', 'run', 'src/server/index.ts', '--host', '127.0.0.1', '--port', String(port)]
@ -257,6 +834,9 @@ describe('diagnostics API', () => {
clientEventUrl.pathname.split('/').filter(Boolean),
)
expect(clientEventRes.status).toBe(200)
const clientEventBody = await clientEventRes.json() as { ok: boolean; eventId: string }
expect(clientEventBody.ok).toBe(true)
expect(clientEventBody.eventId).toBeString()
const clientEvents = await service.readRecentEvents(10)
expect(clientEvents[0].type).toBe('client_unhandled_rejection')
expect(JSON.stringify(clientEvents[0])).toContain('[REDACTED]')
@ -273,4 +853,76 @@ describe('diagnostics API', () => {
expect(clearRes.status).toBe(200)
expect(await service.readRecentEvents()).toEqual([])
})
test('returns a server error when a client diagnostic event cannot be written', async () => {
const recordSpy = spyOn(diagnosticsService, 'recordEvent').mockResolvedValue({
ok: false,
error: 'diagnostic write failed',
})
try {
const request = new Request('http://localhost:3456/api/diagnostics/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'write_probe', summary: 'boom' }),
})
const url = new URL(request.url)
const response = await handleDiagnosticsApi(
request,
url,
url.pathname.split('/').filter(Boolean),
)
expect(response.status).toBeGreaterThanOrEqual(500)
} finally {
recordSpy.mockRestore()
}
})
test('returns a deterministic share-safe GitHub issue report', async () => {
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(
path.join(tmpDir, 'cc-haha', 'providers.json'),
JSON.stringify({
activeId: 'provider-issue-report',
providers: [{
id: 'provider-issue-report',
name: 'Issue Report Provider',
apiFormat: 'anthropic',
baseUrl: 'https://user:pass@api.example.com/private?token=x',
models: { main: 'main-model' },
}],
}),
'utf-8',
)
await diagnosticsService.recordEvent({
type: 'sdk_result_error',
severity: 'error',
summary: 'PRIVATE_ASSISTANT_REPLY',
details: {
errorCode: 'CLI_EXITED',
status: 'failed',
sdkMessages: [{ result: 'PRIVATE_SDK_RESULT' }],
},
})
const request = makeRequest('GET', '/api/diagnostics/issue-report')
const response = await handleDiagnosticsApi(request.req, request.url, request.segments)
const body = await response.json() as { report: string }
expect(response.status).toBe(200)
expect(body.report).toContain('## 问题描述')
expect(body.report).toContain('## 运行环境')
expect(body.report).toContain('## Provider / 模型')
expect(body.report).toContain('## 诊断关联')
expect(body.report).toContain('## 复现步骤')
expect(body.report).toContain('## 错误摘要')
expect(body.report).toContain('CLI_EXITED')
expect(body.report).toContain('api.example.com')
expect(body.report).not.toContain('user:pass')
expect(body.report).not.toContain('/private')
expect(body.report).not.toContain('token=x')
expect(body.report).not.toContain('PRIVATE_ASSISTANT_REPLY')
expect(body.report).not.toContain('PRIVATE_SDK_RESULT')
})
})

View File

@ -72,6 +72,71 @@ function makeRequest(
}
describe('DoctorService', () => {
test('treats absent optional user features on a fresh install as not configured', async () => {
const freshHomeDir = path.join(tmpDir, 'fresh-home')
const freshConfigDir = path.join(freshHomeDir, '.claude')
const service = new DoctorService({ configDir: freshConfigDir, homeDir: freshHomeDir })
const report = await service.getReport()
expect(report.items.length).toBeGreaterThan(0)
expect(report.items.every((item) => item.status === 'not_configured')).toBe(true)
expect(report.summary).toEqual(expect.objectContaining({
total: report.items.length,
neutralCount: report.items.length,
missingCount: 0,
invalidCount: 0,
}))
})
test('treats absent optional project settings, skills, and MCP as not configured', async () => {
const freshProjectRoot = path.join(tmpDir, 'fresh-project')
const service = new DoctorService({ configDir, homeDir, projectRoot: freshProjectRoot })
const report = await service.getReport()
const projectItems = report.items.filter((item) => item.scope === 'project')
expect(projectItems.map((item) => item.id)).toEqual([
'project-settings',
'project-skills',
'project-mcp',
])
expect(projectItems.every((item) => item.status === 'not_configured')).toBe(true)
expect(report.summary.neutralCount).toBeGreaterThanOrEqual(3)
})
test('still reports a configured optional feature when its file is malformed', async () => {
await fs.writeFile(path.join(configDir, 'adapters.json'), '{broken', 'utf-8')
const service = new DoctorService({ configDir, homeDir })
const report = await service.getReport()
const adapters = report.items.find((item) => item.id === 'adapters')
expect(adapters?.status).toBe('invalid_json')
expect(report.summary.invalidCount).toBeGreaterThanOrEqual(1)
expect(report.summary.neutralCount).toBeGreaterThan(0)
})
test('reports schema-invalid managed providers without exposing parsed contents', async () => {
const schemaConfigDir = path.join(homeDir, '.schema-test-claude')
await fs.mkdir(path.join(schemaConfigDir, 'cc-haha'), { recursive: true })
await fs.writeFile(
path.join(schemaConfigDir, 'cc-haha', 'providers.json'),
JSON.stringify({ activeId: null, providers: [{ id: 'provider-1' }] }),
'utf-8',
)
const service = new DoctorService({ configDir: schemaConfigDir, homeDir })
const report = await service.getReport()
const providers = report.items.find((item) => item.id === 'cc-haha-providers')
expect(providers?.status).toBe('invalid_schema')
expect(providers?.error).toContain('providers.0.presetId')
expect(providers?.error).not.toContain('provider-1')
expect(providers?.error).not.toContain(tmpDir)
expect(report.summary.invalidCount).toBe(1)
})
test('report redacts filesystem paths and lists protected skipped items', async () => {
const service = new DoctorService({ configDir, homeDir, projectRoot })

View File

@ -3,6 +3,7 @@
*
* GET /api/diagnostics/status log directory, retention and counters
* GET /api/diagnostics/events recent sanitized diagnostic events
* GET /api/diagnostics/issue-report share-safe GitHub issue report Markdown
* POST /api/diagnostics/events append a sanitized client diagnostic event
* POST /api/diagnostics/export write a sanitized tar.gz bundle
* POST /api/diagnostics/open-log-dir open the diagnostics directory
@ -35,6 +36,10 @@ export async function handleDiagnosticsApi(
return Response.json({ events })
}
if (action === 'issue-report' && req.method === 'GET') {
return Response.json({ report: await diagnosticsService.buildIssueReport() })
}
if (action === 'events' && req.method === 'POST') {
const body = await parseJsonBody(req)
const type = typeof body.type === 'string' && body.type.trim()
@ -46,14 +51,15 @@ export async function handleDiagnosticsApi(
? body.summary
: type
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined
await diagnosticsService.recordEvent({
const result = await diagnosticsService.recordEvent({
type,
severity,
summary,
sessionId,
details: body.details,
})
return Response.json({ ok: true })
if (!result.ok) throw ApiError.internal(result.error)
return Response.json({ ok: true, eventId: result.event.id })
}
if (action === 'export' && req.method === 'POST') {

View File

@ -1680,38 +1680,31 @@ export class ConversationService {
private summarizeSdkMessages(messages: any[]): unknown[] {
return messages.slice(-MAX_CAPTURED_SDK_SUMMARY).map((message) => {
if (!message || typeof message !== 'object') {
return message
return { type: 'unknown' }
}
const content = Array.isArray(message.message?.content)
? message.message.content.map((block: unknown) => {
if (!block || typeof block !== 'object') return block
const typedBlock = block as Record<string, unknown>
return {
type: typedBlock.type,
text:
typeof typedBlock.text === 'string'
? this.redactProcessOutput(typedBlock.text)
: undefined,
}
})
: undefined
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' ? this.redactProcessOutput(message.result) : undefined,
error: typeof message.error === 'string' ? this.redactProcessOutput(message.error) : undefined,
errorDetails:
typeof message.errorDetails === 'string'
? this.redactProcessOutput(message.errorDetails)
: undefined,
message: typeof message.message === 'string' ? this.redactProcessOutput(message.message) : undefined,
content,
type: typeof message.type === 'string' ? message.type : 'unknown',
...(typeof message.subtype === 'string' ? { subtype: message.subtype } : {}),
...(typeof message.is_error === 'boolean' ? { is_error: message.is_error } : {}),
...(this.isSafeSdkStatus(message.status) ? { status: message.status } : {}),
...(this.sdkErrorCategory(message) ? { errorCategory: this.sdkErrorCategory(message) } : {}),
}
})
}
private isSafeSdkStatus(value: unknown): value is string {
return typeof value === 'string' && /^(?:failed|error|success|completed|cancelled|canceled|pending|running)$/i.test(value)
}
private sdkErrorCategory(message: any): string | undefined {
if (message?.type === 'assistant' && (message.isApiErrorMessage === true || message.error !== undefined)) {
return 'api_error'
}
if (message?.type === 'result' && message.is_error === true) return 'result_error'
if (message?.type === 'auth_status') return 'authentication'
return undefined
}
private async buildUserContent(
content: string,
sessionId: string,

View File

@ -1,8 +1,14 @@
import * as fs from 'node:fs/promises'
import { createHash } from 'node:crypto'
import * as os from 'node:os'
import * as path from 'node:path'
import { gzipSync } from 'node:zlib'
import type { Dirent } from 'node:fs'
import {
buildDiagnosticsIssueReport,
projectDiagnosticEventForSharing,
type SharedDiagnosticEvent,
} from './diagnosticsShare.js'
export type DiagnosticSeverity = 'debug' | 'info' | 'warn' | 'error'
@ -29,15 +35,38 @@ export type DiagnosticsStatus = {
diagnosticsPath: string
cliDiagnosticsPath: string
runtimeErrorsPath: string
electronHostPath: string
exportDir: string
retentionDays: number
maxBytes: number
totalBytes: number
storageLimitExceeded: boolean
eventCount: number
physicalLineCount: number
corruptLineCount: number
recentErrorCount: number
lastEventAt: string | null
}
export type DiagnosticWriteResult =
| { ok: true; event: DiagnosticEvent }
| { ok: false; error: string }
type DiagnosticsScanResult = {
events: DiagnosticEvent[]
physicalLineCount: number
corruptLineCount: number
rawCorruptLineCount: number
sourceBytes: number
sourceDigest: string
}
type PendingCorruptionEvidence = {
corruptLineCount: number
sourceBytes: number
sourceDigest: string
}
export type DiagnosticsExportResult = {
path: string
fileName: string
@ -46,11 +75,18 @@ export type DiagnosticsExportResult = {
const RETENTION_DAYS = 7
const MAX_BYTES = 50 * 1024 * 1024
const MAX_DIAGNOSTICS_BYTES = 20 * 1024 * 1024
const MAX_AUXILIARY_LOG_BYTES = 5 * 1024 * 1024
const MAX_CLI_COMPLETED_SEGMENTS_BYTES = 5 * 1024 * 1024
const MAX_EXPORT_DIRECTORY_BYTES = 14 * 1024 * 1024
const MAX_SHARED_EVENTS = 5_000
const MAX_ISSUE_REPORT_EVENTS = 100
const MAX_STRING_LENGTH = 4096
const MAX_TEXT_FILE_EXPORT_LENGTH = 256 * 1024
const MAX_ARRAY_ITEMS = 40
const MAX_OBJECT_KEYS = 80
const MAX_EVENTS_IN_EXPORT = 5000
const RETENTION_SWEEP_INTERVAL_MS = 60 * 1000
const TRUNCATED_OLDER_CONTENT_MARKER = '[TRUNCATED OLDER CONTENT]\n'
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 {
@ -58,6 +94,8 @@ export class DiagnosticsService {
private processCaptureInstalled = false
private originalConsoleError: typeof console.error | null = null
private originalConsoleWarn: typeof console.warn | null = null
private lastRetentionSweepAt = 0
private writeQueue: Promise<void> = Promise.resolve()
getLogDir(): string {
return path.join(this.getConfigDir(), 'cc-haha', 'diagnostics')
@ -75,17 +113,31 @@ export class DiagnosticsService {
return path.join(this.getLogDir(), 'runtime-errors.log')
}
getElectronHostPath(): string {
return path.join(this.getLogDir(), 'electron-host.log')
}
private getCorruptionEvidencePath(): string {
return path.join(this.getLogDir(), 'corruption-evidence.json')
}
private getPendingCorruptionEvidencePath(): string {
return path.join(this.getLogDir(), 'corruption-evidence.pending.json')
}
getExportDir(): string {
return path.join(this.getLogDir(), 'exports')
}
async recordEvent(input: DiagnosticEventInput): Promise<void> {
async recordEvent(input: DiagnosticEventInput): Promise<DiagnosticWriteResult> {
// Test isolation: never let a test run write into the user's real
// ~/.claude/cc-haha/diagnostics. Tests that genuinely exercise diagnostics
// set CLAUDE_CONFIG_DIR to a tmp dir; anything else under NODE_ENV=test is
// a leak (e.g. a fire-and-forget recordEvent resolving after a test's
// afterEach restored CLAUDE_CONFIG_DIR) and must be dropped.
if (process.env.NODE_ENV === 'test' && !process.env.CLAUDE_CONFIG_DIR) return
if (process.env.NODE_ENV === 'test' && !process.env.CLAUDE_CONFIG_DIR) {
return { ok: false, error: 'Diagnostics are disabled without an isolated config directory during tests' }
}
const event: DiagnosticEvent = {
id: crypto.randomUUID(),
@ -99,6 +151,12 @@ export class DiagnosticsService {
...(input.details !== undefined ? { details: this.sanitizeValue(input.details) } : {}),
}
const operation = this.writeQueue.then(() => this.writeEvent(event))
this.writeQueue = operation.then(() => undefined, () => undefined)
return operation
}
private async writeEvent(event: DiagnosticEvent): Promise<DiagnosticWriteResult> {
try {
await this.ensureLogDir()
await fs.appendFile(this.getDiagnosticsPath(), JSON.stringify(event) + '\n', 'utf-8')
@ -106,8 +164,10 @@ export class DiagnosticsService {
await fs.appendFile(this.getRuntimeErrorsPath(), this.formatRuntimeLogEntry(event), 'utf-8')
}
await this.enforceRetention().catch(() => {})
} catch {
return { ok: true, event }
} catch (error) {
// Diagnostics must never break the product path.
return { ok: false, error: this.sanitizeWriteError(error) }
}
}
@ -183,7 +243,9 @@ export class DiagnosticsService {
async getStatus(): Promise<DiagnosticsStatus> {
await this.ensureLogDir()
const events = await this.readRecentEvents(500)
await this.runRetentionSerialized(true, false)
const scan = await this.scanDiagnosticsFile()
const events = scan.events
const totalBytes = await this.getDirectorySize(this.getLogDir())
const cutoff = Date.now() - 24 * 60 * 60 * 1000
return {
@ -191,50 +253,85 @@ export class DiagnosticsService {
diagnosticsPath: this.getDiagnosticsPath(),
cliDiagnosticsPath: this.getCliDiagnosticsPath(),
runtimeErrorsPath: this.getRuntimeErrorsPath(),
electronHostPath: this.getElectronHostPath(),
exportDir: this.getExportDir(),
retentionDays: RETENTION_DAYS,
maxBytes: MAX_BYTES,
totalBytes,
storageLimitExceeded: totalBytes > MAX_BYTES || await this.isManagedSurfaceOverLimit(),
eventCount: events.length,
physicalLineCount: scan.physicalLineCount,
corruptLineCount: scan.corruptLineCount,
recentErrorCount: events.filter((event) =>
(event.severity === 'error' || event.severity === 'warn') &&
Date.parse(event.timestamp) >= cutoff
).length,
lastEventAt: events[0]?.timestamp ?? null,
lastEventAt: events.at(-1)?.timestamp ?? null,
}
}
async readRecentEvents(limit = 100): Promise<DiagnosticEvent[]> {
const boundedLimit = Math.max(1, Math.min(limit, 1000))
const scan = await this.scanDiagnosticsFile()
return scan.events.slice(-boundedLimit).reverse()
}
private async scanDiagnosticsFile(): Promise<DiagnosticsScanResult> {
let raw = ''
try {
raw = await fs.readFile(this.getDiagnosticsPath(), 'utf-8')
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
const persistedCorruptLineCount = await this.readPersistedCorruptLineCount()
return {
events: [],
physicalLineCount: 0,
corruptLineCount: persistedCorruptLineCount,
rawCorruptLineCount: 0,
sourceBytes: 0,
sourceDigest: this.hashBuffer(Buffer.alloc(0)),
}
}
throw err
}
return raw
.split('\n')
.filter(Boolean)
.slice(-boundedLimit)
.map((line) => {
try {
return JSON.parse(line) as DiagnosticEvent
} catch {
return null
const lines = raw.length === 0 ? [] : raw.split('\n')
if (raw.endsWith('\n')) lines.pop()
const events: DiagnosticEvent[] = []
let corruptLineCount = 0
for (const line of lines) {
try {
const parsed = JSON.parse(line) as unknown
if (!this.isDiagnosticEvent(parsed)) {
corruptLineCount += 1
continue
}
})
.filter((event): event is DiagnosticEvent => event !== null)
.reverse()
events.push(parsed)
} catch {
corruptLineCount += 1
}
}
const sourceBuffer = Buffer.from(raw, 'utf-8')
const persistedCorruptLineCount = await this.readPersistedCorruptLineCount(sourceBuffer, corruptLineCount)
return {
events,
physicalLineCount: lines.length,
corruptLineCount: persistedCorruptLineCount + corruptLineCount,
rawCorruptLineCount: corruptLineCount,
sourceBytes: sourceBuffer.byteLength,
sourceDigest: this.hashBuffer(sourceBuffer),
}
}
async exportBundle(): Promise<DiagnosticsExportResult> {
await this.ensureLogDir()
await this.runRetentionSerialized(true, true)
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 scan = await this.scanDiagnosticsFile()
const events = scan.events.slice(-MAX_SHARED_EVENTS).reverse()
const sharedEvents = events.map(projectDiagnosticEventForSharing)
const files = [
{
name: 'README.txt',
@ -246,19 +343,23 @@ export class DiagnosticsService {
},
{
name: 'diagnostics.jsonl',
content: events.map((event) => JSON.stringify(this.sanitizeValue(event))).join('\n') + (events.length ? '\n' : ''),
content: sharedEvents.map((event) => JSON.stringify(event)).join('\n') + (sharedEvents.length ? '\n' : ''),
},
{
name: 'recent-errors.md',
content: this.buildRecentErrorsSummary(events),
content: this.buildRecentErrorsSummary(sharedEvents),
},
{
name: 'runtime-errors.log',
content: await this.readSanitizedTextFile(this.getRuntimeErrorsPath(), MAX_TEXT_FILE_EXPORT_LENGTH),
content: this.buildSharedRuntimeLog(sharedEvents),
},
{
name: 'cli-diagnostics.jsonl',
content: await this.readSanitizedTextFile(this.getCliDiagnosticsPath(), MAX_TEXT_FILE_EXPORT_LENGTH),
content: await this.readCliDiagnosticsForSharing(MAX_TEXT_FILE_EXPORT_LENGTH),
},
{
name: 'electron-host.log',
content: await this.readSanitizedTextFile(this.getElectronHostPath(), MAX_TEXT_FILE_EXPORT_LENGTH),
},
{
name: 'providers-summary.json',
@ -266,16 +367,30 @@ export class DiagnosticsService {
},
{
name: 'sessions-summary.json',
content: JSON.stringify(this.buildSessionsSummary(events), null, 2) + '\n',
content: JSON.stringify(this.buildSessionsSummary(sharedEvents), null, 2) + '\n',
},
]
const archive = this.createTarGz(files)
await fs.mkdir(this.getExportDir(), { recursive: true })
await fs.writeFile(outPath, archive)
await this.enforceExportRetention(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000)
return { path: outPath, fileName, bytes: archive.byteLength }
}
async buildIssueReport(): Promise<string> {
await this.ensureLogDir()
await this.runRetentionSerialized(true, true)
const scan = await this.scanDiagnosticsFile()
return buildDiagnosticsIssueReport({
generatedAt: new Date().toISOString(),
appInfo: this.buildAppInfo(),
providersSummary: await this.buildProvidersSummary(),
events: scan.events.slice(-MAX_ISSUE_REPORT_EVENTS).reverse().map(projectDiagnosticEventForSharing),
corruptLineCount: scan.corruptLineCount,
})
}
async openLogDir(): Promise<void> {
await this.ensureLogDir()
const dir = this.getLogDir()
@ -336,6 +451,9 @@ export class DiagnosticsService {
.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]')
.replace(/\bAKIA[A-Z0-9]{16}\b/g, '[REDACTED_AWS_ACCESS_KEY]')
.replace(/(?:\/Users|\/home|\/private|\/var\/folders|\/tmp)\/[^\s"'`]+/g, '[REDACTED_PATH]')
.replace(/\b[A-Z]:\\(?:[^\s"'`]+\\)*[^\s"'`]*/gi, '[REDACTED_PATH]')
const home = os.homedir()
if (home && sanitized.includes(home)) {
@ -406,22 +524,25 @@ export class DiagnosticsService {
'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.',
'Content-bearing fields are omitted and secrets/personal data receive best-effort redaction.',
'Included metadata may contain event ids, timestamps, event types, severity, session ids, error codes/status, provider/model identifiers, and provider hostnames.',
'Paths under the current home directory are normalized to "~". Long fields are truncated.',
'Review every file before sharing because automated redaction cannot guarantee removal of all sensitive data.',
'',
'Files:',
'- app-info.json: runtime and platform summary.',
'- diagnostics.jsonl: sanitized structured diagnostic events.',
'- recent-errors.md: human-readable warning/error timeline for GitHub issues.',
'- runtime-errors.log: sanitized warning/error timeline with captured runtime details.',
'- runtime-errors.log: warning/error timeline with projected diagnostic metadata/details.',
'- cli-diagnostics.jsonl: sanitized no-PII CLI internal diagnostics emitted by the child process.',
'- electron-host.log: sanitized recent Electron host lifecycle diagnostics.',
'- 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 buildRecentErrorsSummary(events: DiagnosticEvent[]): string {
private buildRecentErrorsSummary(events: SharedDiagnosticEvent[]): string {
const errorEvents = events
.filter((event) => event.severity === 'error' || event.severity === 'warn')
.slice(0, 50)
@ -444,7 +565,6 @@ export class DiagnosticsService {
lines.push(`## ${event.timestamp} ${event.severity.toUpperCase()} ${event.type}`)
if (event.sessionId) lines.push(`session: ${event.sessionId}`)
lines.push('')
lines.push(event.summary)
if (event.details !== undefined) {
lines.push('')
lines.push('```json')
@ -457,6 +577,20 @@ export class DiagnosticsService {
return lines.join('\n')
}
private buildSharedRuntimeLog(events: SharedDiagnosticEvent[]): string {
const errorEvents = events.filter((event) => event.severity === 'error' || event.severity === 'warn')
return errorEvents
.map((event) => {
const lines = [
`[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}`,
]
if (event.details !== undefined) lines.push(JSON.stringify(event.details, null, 2))
if (event.omittedFields.length > 0) lines.push(`omittedFields: ${event.omittedFields.join(', ')}`)
return lines.join('\n')
})
.join('\n\n') + (errorEvents.length > 0 ? '\n' : '')
}
private buildAppInfo(): Record<string, unknown> {
return this.sanitizeValue({
appVersion: process.env.APP_VERSION || '999.0.0-local',
@ -464,7 +598,6 @@ export class DiagnosticsService {
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>
@ -502,13 +635,13 @@ export class DiagnosticsService {
if (!value.trim()) return null
try {
const url = new URL(value)
return { protocol: url.protocol, host: url.host, pathname: url.pathname }
return { hostname: url.hostname }
} catch {
return { value: this.sanitizeString(value, 512) }
return null
}
}
private buildSessionsSummary(events: DiagnosticEvent[]): Record<string, unknown> {
private buildSessionsSummary(events: SharedDiagnosticEvent[]): Record<string, unknown> {
const sessions = new Map<string, { eventCount: number; lastEventAt: string; severities: Set<DiagnosticSeverity> }>()
for (const event of events) {
if (!event.sessionId) continue
@ -538,32 +671,345 @@ export class DiagnosticsService {
filePath: string,
maxLength = 2 * MAX_STRING_LENGTH,
): Promise<string> {
let file: fs.FileHandle | undefined
try {
return this.sanitizeString(await fs.readFile(filePath, 'utf-8'), maxLength)
file = await fs.open(filePath, 'r')
const stat = await file.stat()
if (stat.size <= maxLength) {
return this.sanitizeString(await file.readFile('utf-8'), maxLength)
}
const readLength = Math.min(stat.size, maxLength + MAX_STRING_LENGTH)
const start = stat.size - readLength
const buffer = Buffer.alloc(readLength)
await file.read(buffer, 0, readLength, start)
let rawTail = buffer.toString('utf-8')
if (start > 0) {
const firstCompleteLine = rawTail.indexOf('\n')
rawTail = firstCompleteLine === -1 ? '' : rawTail.slice(firstCompleteLine + 1)
}
const sanitizedTail = this.sanitizeString(rawTail, Number.MAX_SAFE_INTEGER)
return TRUNCATED_OLDER_CONTENT_MARKER + sanitizedTail.slice(-maxLength)
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return ''
throw err
} finally {
await file?.close()
}
}
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)
private async readCliDiagnosticsForSharing(maxLength: number): Promise<string> {
const baseName = path.basename(this.getCliDiagnosticsPath())
const successfullyReadPaths = new Set<string>()
const seenContentDigests = new Set<string>()
const chunks: string[] = []
// A writer may atomically rotate current -> completed between listing and
// opening. Re-list a fixed number of times so the moved inode is normally
// picked up under its new name without ever locking or mutating it.
for (let attempt = 0; attempt < 3; attempt += 1) {
const files = (await this.listFiles(this.getLogDir()))
.filter((file) => path.dirname(file.path) === this.getLogDir())
.filter((file) => {
const name = path.basename(file.path)
return name === baseName || (name.startsWith(`${baseName}.`) && name.endsWith('.jsonl'))
})
.sort((left, right) => left.mtimeMs - right.mtimeMs)
for (const file of files) {
if (successfullyReadPaths.has(file.path)) continue
const content = await this.readSanitizedTextFile(file.path, maxLength)
if (!content) continue
successfullyReadPaths.add(file.path)
const digest = this.hashBuffer(Buffer.from(content, 'utf-8'))
if (seenContentDigests.has(digest)) continue
seenContentDigests.add(digest)
chunks.push(content)
}
}
const combined = chunks.join('')
return combined.length > maxLength
? TRUNCATED_OLDER_CONTENT_MARKER + combined.slice(-maxLength)
: combined
}
for (const file of files) {
if (file.mtimeMs < cutoff) {
await fs.rm(file.path, { force: true })
private async runRetentionSerialized(force: boolean, compactStructured: boolean): Promise<void> {
const operation = this.writeQueue.then(() => this.enforceRetention(force, compactStructured))
this.writeQueue = operation.then(() => undefined, () => undefined)
await operation
}
private async enforceRetention(force = false, compactStructured = true): Promise<void> {
const diagnosticsPath = this.getDiagnosticsPath()
const cutoff = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000
const stat = await fs.stat(diagnosticsPath).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return null
throw error
})
const forceCompaction = (stat?.size ?? 0) > MAX_DIAGNOSTICS_BYTES
const now = Date.now()
if (!force && !forceCompaction && now - this.lastRetentionSweepAt < RETENTION_SWEEP_INTERVAL_MS) return
if (stat) {
const scan = await this.scanDiagnosticsFile()
const hasStaleEvent = scan.events.some((event) => Date.parse(event.timestamp) < cutoff)
if (compactStructured || forceCompaction || hasStaleEvent) {
if (scan.rawCorruptLineCount > 0) {
await this.writePendingCorruptLineCount({
corruptLineCount: scan.corruptLineCount,
sourceBytes: scan.sourceBytes,
sourceDigest: scan.sourceDigest,
})
}
const retained = scan.events
.filter((event) => Date.parse(event.timestamp) >= cutoff)
.sort((left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp))
const newestLinesThatFit = this.keepNewestCompleteLinesWithinBytes(retained, MAX_DIAGNOSTICS_BYTES)
await this.rewriteDiagnosticsAtomically(newestLinesThatFit)
if (scan.rawCorruptLineCount > 0) await this.commitPendingCorruptLineCount()
}
}
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
await Promise.all([
this.truncateTextFileToTail(this.getRuntimeErrorsPath(), MAX_AUXILIARY_LOG_BYTES),
this.truncateTextFileToTail(this.getElectronHostPath(), MAX_AUXILIARY_LOG_BYTES),
this.enforceCliCompletedSegmentRetention(cutoff),
])
await this.enforceExportRetention(cutoff)
this.lastRetentionSweepAt = now
}
private async readPersistedCorruptLineCount(
sourceBuffer = Buffer.alloc(0),
rawCorruptLineCount = 0,
): Promise<number> {
let committed = 0
try {
const parsed = JSON.parse(await fs.readFile(this.getCorruptionEvidencePath(), 'utf-8')) as unknown
if (parsed && typeof parsed === 'object') {
const count = (parsed as Record<string, unknown>).corruptLineCount
committed = typeof count === 'number' && Number.isSafeInteger(count) && count >= 0 ? count : 0
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error
}
const pending = await this.readPendingCorruptEvidence()
if (!pending) return committed
const pendingSourceStillPresent = sourceBuffer.byteLength >= pending.sourceBytes &&
this.hashBuffer(sourceBuffer.subarray(0, pending.sourceBytes)) === pending.sourceDigest
if (rawCorruptLineCount > 0 && pendingSourceStillPresent) return committed
return Math.max(committed, pending.corruptLineCount)
}
private async readPendingCorruptEvidence(): Promise<PendingCorruptionEvidence | null> {
try {
const parsed = JSON.parse(await fs.readFile(this.getPendingCorruptionEvidencePath(), 'utf-8')) as unknown
if (!parsed || typeof parsed !== 'object') return null
const record = parsed as Record<string, unknown>
return typeof record.corruptLineCount === 'number' && Number.isSafeInteger(record.corruptLineCount) && record.corruptLineCount >= 0 &&
typeof record.sourceBytes === 'number' && Number.isSafeInteger(record.sourceBytes) && record.sourceBytes >= 0 &&
typeof record.sourceDigest === 'string'
? record as PendingCorruptionEvidence
: null
} catch {
return null
}
}
private async writePendingCorruptLineCount(evidence: PendingCorruptionEvidence): Promise<void> {
await fs.writeFile(
this.getPendingCorruptionEvidencePath(),
`${JSON.stringify(evidence)}\n`,
{ encoding: 'utf-8', mode: 0o600 },
)
}
private async commitPendingCorruptLineCount(): Promise<void> {
await fs.rename(this.getPendingCorruptionEvidencePath(), this.getCorruptionEvidencePath())
}
private hashBuffer(buffer: Buffer): string {
return createHash('sha256').update(buffer).digest('hex')
}
private async truncateTextFileToTail(filePath: string, maxBytes: number): Promise<void> {
let file: fs.FileHandle | undefined
try {
file = await fs.open(filePath, 'r')
const stat = await file.stat()
if (stat.size <= maxBytes) return
const readLength = Math.min(stat.size, maxBytes + MAX_STRING_LENGTH)
const buffer = Buffer.alloc(readLength)
await file.read(buffer, 0, readLength, stat.size - readLength)
await file.close()
file = undefined
let tail = buffer.toString('utf-8')
const firstCompleteLine = tail.indexOf('\n')
tail = firstCompleteLine === -1 ? tail.slice(-maxBytes) : tail.slice(firstCompleteLine + 1)
while (Buffer.byteLength(tail, 'utf-8') > maxBytes) tail = tail.slice(1)
const temporaryPath = `${filePath}.${crypto.randomUUID()}.tmp`
try {
await fs.writeFile(temporaryPath, tail, { encoding: 'utf-8', mode: 0o600 })
await fs.rename(temporaryPath, filePath)
} finally {
await fs.rm(temporaryPath, { force: true })
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
} finally {
await file?.close()
}
}
private async enforceExportRetention(cutoff: number): Promise<void> {
const files = (await this.listFiles(this.getExportDir())).sort((left, right) => left.mtimeMs - right.mtimeMs)
let retainedBytes = files.reduce((sum, file) => sum + file.size, 0)
for (const file of files) {
if (file.mtimeMs >= cutoff && retainedBytes <= MAX_EXPORT_DIRECTORY_BYTES) continue
await fs.rm(file.path, { force: true })
retainedBytes -= file.size
}
}
private async enforceCliCompletedSegmentRetention(cutoff: number): Promise<void> {
const baseName = path.basename(this.getCliDiagnosticsPath())
const cliFiles = (await this.listFiles(this.getLogDir()))
.filter((file) => path.dirname(file.path) === this.getLogDir())
.filter((file) => {
const name = path.basename(file.path)
return name === baseName || (name.startsWith(`${baseName}.`) && name.endsWith('.jsonl'))
})
const removed = new Set<string>()
for (const file of cliFiles) {
const name = path.basename(file.path)
if (name.includes('.reclaimed.')) {
if (file.mtimeMs < cutoff) {
await fs.rm(file.path, { force: true })
removed.add(file.path)
}
continue
}
if (name === baseName) {
if (file.mtimeMs < cutoff) {
await this.quarantineCliAppendTarget(file.path)
removed.add(file.path)
}
continue
}
if (!name.endsWith('.current.jsonl')) continue
const pidMatch = name.match(new RegExp(`^${this.escapeRegExp(baseName)}\\.(\\d+)\\.current\\.jsonl$`))
const liveness = pidMatch ? this.getPidLiveness(Number(pidMatch[1])) : 'unknown'
if (liveness === 'dead' || file.mtimeMs < cutoff) {
await this.quarantineCliAppendTarget(file.path)
removed.add(file.path)
}
}
const recentLegacyBytes = cliFiles
.filter((file) => path.basename(file.path) === baseName && !removed.has(file.path))
.reduce((sum, file) => sum + file.size, 0)
const completed = cliFiles
.filter((file) => !removed.has(file.path))
.filter((file) => {
const name = path.basename(file.path)
return name !== baseName && !name.endsWith('.current.jsonl') && !name.includes('.reclaimed.')
})
.sort((left, right) => left.mtimeMs - right.mtimeMs)
let retainedBytes = recentLegacyBytes + completed.reduce((sum, file) => sum + file.size, 0)
for (const file of completed) {
if (file.mtimeMs >= cutoff && retainedBytes <= MAX_CLI_COMPLETED_SEGMENTS_BYTES) continue
await fs.rm(file.path, { force: true })
retainedBytes -= file.size
}
}
private async quarantineCliAppendTarget(filePath: string): Promise<void> {
const currentSuffix = '.current.jsonl'
const stem = filePath.endsWith(currentSuffix)
? filePath.slice(0, -currentSuffix.length)
: filePath
const quarantinePath = `${stem}.reclaimed.${crypto.randomUUID()}.jsonl`
try {
await fs.rename(filePath, quarantinePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
}
private getPidLiveness(pid: number): 'alive' | 'dead' | 'unknown' {
if (!Number.isSafeInteger(pid) || pid <= 0) return 'unknown'
try {
process.kill(pid, 0)
return 'alive'
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ESRCH') return 'dead'
if (code === 'EPERM') return 'alive'
return 'unknown'
}
}
private escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
private async isManagedSurfaceOverLimit(): Promise<boolean> {
const files = await this.listFiles(this.getLogDir())
const sizeOf = (filePath: string) => files.find((file) => file.path === filePath)?.size ?? 0
const cliBaseName = path.basename(this.getCliDiagnosticsPath())
const cliBytes = files
.filter((file) => path.dirname(file.path) === this.getLogDir())
.filter((file) => {
const name = path.basename(file.path)
return name === cliBaseName || (name.startsWith(`${cliBaseName}.`) && name.endsWith('.jsonl'))
})
.reduce((sum, file) => sum + file.size, 0)
const exportBytes = files
.filter((file) => file.path.startsWith(`${this.getExportDir()}${path.sep}`))
.reduce((sum, file) => sum + file.size, 0)
return sizeOf(this.getDiagnosticsPath()) > MAX_DIAGNOSTICS_BYTES ||
sizeOf(this.getRuntimeErrorsPath()) > MAX_AUXILIARY_LOG_BYTES ||
sizeOf(this.getElectronHostPath()) > MAX_AUXILIARY_LOG_BYTES ||
cliBytes > MAX_CLI_COMPLETED_SEGMENTS_BYTES ||
exportBytes > MAX_EXPORT_DIRECTORY_BYTES
}
private keepNewestCompleteLinesWithinBytes(events: DiagnosticEvent[], maxBytes: number): string[] {
const kept: string[] = []
let totalBytes = 0
for (let index = events.length - 1; index >= 0; index -= 1) {
const line = JSON.stringify(events[index])
const lineBytes = Buffer.byteLength(line, 'utf-8') + 1
if (lineBytes > maxBytes - totalBytes) break
kept.push(line)
totalBytes += lineBytes
}
return kept.reverse()
}
private async rewriteDiagnosticsAtomically(lines: string[]): Promise<void> {
const diagnosticsPath = this.getDiagnosticsPath()
const temporaryPath = `${diagnosticsPath}.${crypto.randomUUID()}.tmp`
const content = lines.length > 0 ? `${lines.join('\n')}\n` : ''
try {
await fs.writeFile(temporaryPath, content, 'utf-8')
await fs.rename(temporaryPath, diagnosticsPath)
} finally {
await fs.rm(temporaryPath, { force: true })
}
}
private sanitizeWriteError(error: unknown): string {
const raw = error instanceof Error ? error.message : String(error)
return this.sanitizeString(raw).split(this.getConfigDir()).join('$CLAUDE_CONFIG_DIR')
}
private isDiagnosticEvent(value: unknown): value is DiagnosticEvent {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
const event = value as Record<string, unknown>
return typeof event.id === 'string' &&
typeof event.timestamp === 'string' &&
Number.isFinite(Date.parse(event.timestamp)) &&
typeof event.type === 'string' &&
(event.severity === 'debug' || event.severity === 'info' || event.severity === 'warn' || event.severity === 'error') &&
typeof event.summary === 'string' &&
(event.sessionId === undefined || typeof event.sessionId === 'string')
}
private async getDirectorySize(dir: string): Promise<number> {

View File

@ -0,0 +1,177 @@
import { describe, expect, test } from 'bun:test'
import type { DiagnosticEvent } from './diagnosticsService.js'
import {
buildDiagnosticsIssueReport,
projectDiagnosticEventForSharing,
} from './diagnosticsShare.js'
describe('projectDiagnosticEventForSharing', () => {
test('reduces native and serialized errors to metadata without messages, stacks, paths, prompts, or cloud keys', () => {
const nativeError = new Error('PRIVATE_PROMPT at /Users/alice/private/project AKIAIOSFODNN7EXAMPLE')
nativeError.name = 'PRIVATE_PROMPT_CONTENT'
nativeError.stack = 'ProviderRequestError: PRIVATE_STACK\n at /Users/alice/private/project/index.ts:42:1'
const event: DiagnosticEvent = {
id: 'event-AKIAIOSFODNN7EXAMPLE',
timestamp: '2026-07-11T09:10:11.000Z',
type: 'provider_error',
severity: 'error',
summary: 'PRIVATE_SUMMARY',
sessionId: '/Users/alice/private/session',
details: {
nativeError,
serializedError: {
name: 'PRIVATE_SERIALIZED_ERROR_NAME',
message: 'PRIVATE_SERIALIZED_MESSAGE wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
stack: 'PRIVATE_SERIALIZED_STACK /home/alice/private.txt',
},
},
}
const projected = projectDiagnosticEventForSharing(event)
const serialized = JSON.stringify(projected)
expect(projected.details).toEqual({
nativeError: { name: 'UnknownError' },
serializedError: { name: 'UnknownError' },
})
for (const privateValue of [
'PRIVATE_PROMPT',
'PRIVATE_STACK',
'PRIVATE_SERIALIZED_MESSAGE',
'PRIVATE_SERIALIZED_STACK',
'PRIVATE_PROMPT_CONTENT',
'PRIVATE_SERIALIZED_ERROR_NAME',
'/Users/alice',
'/home/alice',
'AKIAIOSFODNN7EXAMPLE',
'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
]) {
expect(serialized).not.toContain(privateValue)
}
expect(projected.omittedFields).toContain('details.nativeError.message')
expect(projected.omittedFields).toContain('details.nativeError.stack')
expect(projected.omittedFields).toContain('details.serializedError.message')
expect(projected.omittedFields).toContain('details.serializedError.stack')
})
test('keeps diagnostic metadata while omitting content and personal data', () => {
const error = new Error('request failed for sk-proj-PROJECTSECRET at user@example.com')
const event: DiagnosticEvent = {
id: 'event-share-safe-1',
timestamp: '2026-07-11T09:10:11.000Z',
type: 'sdk_result_error',
severity: 'error',
summary: 'PRIVATE_ASSISTANT_REPLY',
sessionId: 'session-safe-id',
details: {
errorCode: 'CLI_START_FAILED',
status: 'failed',
content: 'PRIVATE_CONTENT',
prompt: 'PRIVATE_PROMPT',
response: 'PRIVATE_RESPONSE',
capturedOutput: 'PRIVATE_CAPTURED_OUTPUT',
sdkMessages: [{ message: { content: [{ type: 'text', text: 'PRIVATE_ASSISTANT_REPLY' }] } }],
toolInput: 'PRIVATE_TOOL_INPUT',
toolOutput: 'PRIVATE_TOOL_OUTPUT',
assistantText: 'PRIVATE_ASSISTANT_TEXT',
bareAnthropicToken: 'sk-ant-api03-BARESECRET',
projectToken: 'sk-proj-PROJECTSECRET',
githubToken: 'ghp_GITHUBSECRET',
endpoint: 'https://private-user:private-pass@example.com/private/path?token=query-secret',
email: 'user@example.com',
error,
},
}
const projected = projectDiagnosticEventForSharing(event)
const serialized = JSON.stringify(projected)
for (const privateValue of [
'PRIVATE_CONTENT',
'PRIVATE_PROMPT',
'PRIVATE_RESPONSE',
'PRIVATE_CAPTURED_OUTPUT',
'PRIVATE_ASSISTANT_REPLY',
'PRIVATE_TOOL_INPUT',
'PRIVATE_TOOL_OUTPUT',
'PRIVATE_ASSISTANT_TEXT',
'sk-ant-api03-BARESECRET',
'sk-proj-PROJECTSECRET',
'ghp_GITHUBSECRET',
'private-user',
'private-pass',
'/private/path',
'query-secret',
'user@example.com',
]) {
expect(serialized).not.toContain(privateValue)
}
expect(projected.id).toBe(event.id)
expect(projected.type).toBe(event.type)
expect(projected.severity).toBe(event.severity)
expect(projected.details).toMatchObject({ errorCode: 'CLI_START_FAILED', status: 'failed' })
expect(projected.omittedFields).toContain('summary')
expect(projected.omittedFields).toContain('details.sdkMessages')
expect(projected.omittedFields).toContain('details.error.message')
expect(projected.omittedFields).toContain('details.error.stack')
})
})
describe('buildDiagnosticsIssueReport', () => {
test('builds a deterministic share-safe GitHub issue template', () => {
const report = buildDiagnosticsIssueReport({
generatedAt: '2026-07-11T09:10:11.000Z',
appInfo: {
appVersion: '0.4.7',
platform: 'darwin',
arch: 'arm64',
bun: '1.2.18',
node: 'v22.17.0',
},
providersSummary: {
activeId: 'provider-1',
count: 1,
providers: [{
id: 'provider-1',
name: 'Test Provider',
apiFormat: 'anthropic',
baseUrl: { hostname: 'api.example.com' },
models: { main: 'main-model' },
}],
},
events: [{
id: 'event-report-1',
timestamp: '2026-07-11T09:00:00.000Z',
type: 'sdk_api_error',
severity: 'error',
details: {
errorCode: 'API_ERROR',
status: 'failed',
content: 'PRIVATE_ASSISTANT_REPLY',
},
omittedFields: ['summary', 'details.sdkMessages'],
}],
corruptLineCount: 2,
})
expect(report).toContain('## 问题描述')
expect(report).toContain('期望行为')
expect(report).toContain('出现频率')
expect(report).toContain('## 运行环境')
expect(report).toContain('- App: 0.4.7')
expect(report).toContain('- OS/Arch: darwin / arm64')
expect(report).toContain('- Bun/Node: 1.2.18 / v22.17.0')
expect(report).toContain('- 安装来源: <!-- 请补充 -->')
expect(report).toContain('## Provider / 模型')
expect(report).toContain('api.example.com')
expect(report).toContain('main-model')
expect(report).toContain('## 诊断关联')
expect(report).toContain('- Event IDs: event-report-1')
expect(report).toContain('- Corrupt diagnostic lines: 2')
expect(report).toContain('检测到 2 行损坏的诊断记录')
expect(report).toContain('## 复现步骤')
expect(report).toContain('## 错误摘要')
expect(report).toContain('2026-07-11T09:00:00.000Z')
expect(report).not.toContain('PRIVATE_ASSISTANT_REPLY')
})
})

View File

@ -0,0 +1,263 @@
import type { DiagnosticEvent, DiagnosticSeverity } from './diagnosticsService.js'
export type SharedDiagnosticEvent = {
id: string
timestamp: string
type: string
severity: DiagnosticSeverity
sessionId?: string
details?: Record<string, unknown>
omittedFields: string[]
}
export type DiagnosticsIssueReportInput = {
generatedAt: string
appInfo: Record<string, unknown>
providersSummary: Record<string, unknown>
events: SharedDiagnosticEvent[]
corruptLineCount: number
}
const CONTENT_BEARING_KEYS = new Set([
'assistanttext',
'body',
'capturedoutput',
'content',
'filecontent',
'filecontents',
'input',
'message',
'messagetext',
'output',
'prompt',
'response',
'result',
'sdkmessages',
'text',
'toolinput',
'tooloutput',
'transcript',
])
const SAFE_SCALAR_KEYS = new Set([
'code',
'errorcategory',
'errorcode',
'is_error',
'isapierrormessage',
'iserror',
'name',
'sdkType'.toLowerCase(),
'status',
'subtype',
])
const SAFE_METADATA_VALUE_RE = /^[a-z0-9][a-z0-9_.:/ -]{0,127}$/i
const URL_RE = /https?:\/\/[^\s<>"')\]}]+/gi
const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi
const SECRET_RE = /\b(?:sk-ant-api03-|sk-proj-|ghp_)[A-Za-z0-9_-]+\b/g
const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+/-]+/gi
const AWS_ACCESS_KEY_RE = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g
const PRIVATE_PATH_RE = /(?:\/Users|\/home|\/private|\/var\/folders|\/tmp)\/[^\s<>"')\]}]+/g
const WINDOWS_PATH_RE = /\b[A-Z]:\\(?:[^\s<>"')\]}]+\\)*[^\s<>"')\]}]*/gi
const SAFE_ERROR_NAMES = new Set([
'AggregateError',
'Error',
'EvalError',
'RangeError',
'ReferenceError',
'SyntaxError',
'TypeError',
'URIError',
])
export function projectDiagnosticEventForSharing(event: DiagnosticEvent): SharedDiagnosticEvent {
const omittedFields = ['summary']
const details = projectDetails(event.details, 'details', omittedFields)
return {
id: sanitizeSharedString(event.id),
timestamp: sanitizeSharedString(event.timestamp),
type: sanitizeSharedString(event.type),
severity: event.severity,
...(event.sessionId ? { sessionId: sanitizeSharedString(event.sessionId) } : {}),
...(details ? { details } : {}),
omittedFields: [...new Set(omittedFields)].sort(),
}
}
export function buildDiagnosticsIssueReport(input: DiagnosticsIssueReportInput): string {
const eventIds = input.events.map((event) => event.id).join(', ') || 'None'
const providers = Array.isArray(input.providersSummary.providers)
? input.providersSummary.providers as Array<Record<string, unknown>>
: []
const providerLines = providers.length > 0
? providers.map(formatProviderLine)
: ['- 未配置 Provider']
const recentErrors = input.events
.filter((event) => event.severity === 'error' || event.severity === 'warn')
.slice(0, 50)
const errorLines = recentErrors.length > 0
? recentErrors.map(formatErrorLine)
: ['- 未记录最近的警告或错误。']
const corruptionWarning = input.corruptLineCount > 0
? `> 警告:检测到 ${input.corruptLineCount} 行损坏的诊断记录,以下信息可能不完整。`
: '> 未检测到损坏的诊断记录。'
return [
`<!-- Generated: ${sanitizeSharedString(input.generatedAt)} -->`,
'',
'## 问题描述',
'<!-- 请补充 -->',
'',
'- 期望行为: <!-- 请补充 -->',
'- 出现频率: <!-- 请补充 -->',
'',
'## 运行环境',
`- App: ${formatMetadata(input.appInfo.appVersion)}`,
`- OS/Arch: ${formatMetadata(input.appInfo.platform)} / ${formatMetadata(input.appInfo.arch)}`,
`- Bun/Node: ${formatMetadata(input.appInfo.bun)} / ${formatMetadata(input.appInfo.node)}`,
'- 安装来源: <!-- 请补充 -->',
'',
'## Provider / 模型',
...providerLines,
'',
'## 诊断关联',
`- Event IDs: ${eventIds}`,
`- Corrupt diagnostic lines: ${input.corruptLineCount}`,
'',
corruptionWarning,
'',
'## 复现步骤',
'1. <!-- 请补充 -->',
'',
'## 错误摘要',
...errorLines,
'',
].join('\n')
}
function projectDetails(
value: unknown,
path: string,
omittedFields: string[],
): Record<string, unknown> | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
if (value !== undefined) omittedFields.push(path)
return undefined
}
if (value instanceof Error) {
omittedFields.push(`${path}.message`, `${path}.stack`)
return { error: projectError(value) }
}
const projected: Record<string, unknown> = {}
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
const entryPath = `${path}.${key}`
const normalizedKey = key.toLowerCase()
if (CONTENT_BEARING_KEYS.has(normalizedKey)) {
omittedFields.push(entryPath)
continue
}
if (entry instanceof Error) {
omittedFields.push(`${entryPath}.message`, `${entryPath}.stack`)
projected[key] = projectError(entry)
continue
}
if (isSerializedError(entry)) {
omittedFields.push(`${entryPath}.message`, `${entryPath}.stack`)
projected[key] = projectSerializedError(entry)
continue
}
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
const nested = projectDetails(entry, entryPath, omittedFields)
if (nested && Object.keys(nested).length > 0) projected[key] = nested
continue
}
if (SAFE_SCALAR_KEYS.has(normalizedKey) && isScalar(entry)) {
projected[key] = projectSafeMetadataScalar(entry)
continue
}
omittedFields.push(entryPath)
}
return Object.keys(projected).length > 0 ? projected : undefined
}
function projectError(error: Error): Record<string, string> {
return {
name: projectErrorName(error.name),
}
}
function isSerializedError(value: unknown): value is { name: string; message: string; stack?: string } {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
const record = value as Record<string, unknown>
const keys = Object.keys(record)
return typeof record.name === 'string' && typeof record.message === 'string' &&
keys.every((key) => key === 'name' || key === 'message' || key === 'stack')
}
function projectSerializedError(error: { name: string; message: string; stack?: string }): Record<string, string> {
return {
name: projectErrorName(error.name),
}
}
function projectErrorName(name: string): string {
return SAFE_ERROR_NAMES.has(name) ? name : 'UnknownError'
}
function isScalar(value: unknown): value is string | number | boolean | null {
return value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
}
function projectSafeMetadataScalar(value: string | number | boolean | null): string | number | boolean | null {
if (typeof value !== 'string') return value
const sanitized = sanitizeSharedString(value)
return SAFE_METADATA_VALUE_RE.test(sanitized) ? sanitized : '[REDACTED]'
}
function sanitizeSharedString(value: string): string {
return value
.replace(URL_RE, (candidate) => {
try {
return new URL(candidate).hostname
} catch {
return '[REDACTED_URL]'
}
})
.replace(SECRET_RE, '[REDACTED]')
.replace(BEARER_RE, 'Bearer [REDACTED]')
.replace(EMAIL_RE, '[REDACTED_EMAIL]')
.replace(AWS_ACCESS_KEY_RE, '[REDACTED_AWS_ACCESS_KEY]')
.replace(PRIVATE_PATH_RE, '[REDACTED_PATH]')
.replace(WINDOWS_PATH_RE, '[REDACTED_PATH]')
}
function formatMetadata(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return sanitizeSharedString(String(value))
}
return 'unknown'
}
function formatProviderLine(provider: Record<string, unknown>): string {
const baseUrl = provider.baseUrl && typeof provider.baseUrl === 'object'
? provider.baseUrl as Record<string, unknown>
: {}
const models = provider.models && typeof provider.models === 'object' && !Array.isArray(provider.models)
? Object.entries(provider.models as Record<string, unknown>)
.filter(([, value]) => typeof value === 'string')
.map(([key, value]) => `${sanitizeSharedString(key)}=${sanitizeSharedString(String(value))}`)
.join(', ')
: 'None'
return `- ${formatMetadata(provider.name ?? provider.id)} | ${formatMetadata(provider.apiFormat)} | Host: ${formatMetadata(baseUrl.hostname)} | Models: ${models || 'None'}`
}
function formatErrorLine(event: SharedDiagnosticEvent): string {
const details = event.details ?? {}
const metadata = [
typeof details.errorCode === 'string' ? `errorCode=${details.errorCode}` : '',
typeof details.status === 'string' ? `status=${details.status}` : '',
].filter(Boolean).join(', ')
return `- ${event.timestamp} [${event.severity.toUpperCase()}] ${event.type} (${event.id})${metadata ? `${metadata}` : ''}`
}

View File

@ -3,10 +3,11 @@ import * as os from 'node:os'
import * as path from 'node:path'
import type { Dirent } from 'node:fs'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { ProvidersIndexSchema } from '../types/provider.js'
import { diagnosticsService } from './diagnosticsService.js'
export type DoctorItemKind = 'json' | 'jsonl' | 'directory'
export type DoctorItemStatus = 'ok' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'unreadable'
export type DoctorItemStatus = 'ok' | 'not_configured' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'invalid_schema' | 'unreadable'
export type DoctorSkipReason = 'protected'
export type DoctorReportItem = {
@ -38,6 +39,7 @@ export type DoctorReport = {
summary: {
total: number
protectedCount: number
neutralCount: number
missingCount: number
invalidCount: number
}
@ -79,6 +81,7 @@ type DoctorTarget = {
scope: 'user' | 'project'
filePath: string
protected: true
required: boolean
}
export class DoctorService {
@ -112,10 +115,12 @@ export class DoctorService {
summary: {
total: items.length,
protectedCount: protectedSkips.length,
neutralCount: items.filter((item) => item.status === 'not_configured').length,
missingCount: items.filter((item) => item.status === 'missing').length,
invalidCount: items.filter((item) =>
item.status === 'invalid_json' ||
item.status === 'invalid_jsonl' ||
item.status === 'invalid_schema' ||
item.status === 'unreadable'
).length,
},
@ -273,6 +278,21 @@ export class DoctorService {
try {
const parsed = JSON.parse(raw)
if (target.id === 'cc-haha-providers') {
const result = ProvidersIndexSchema.safeParse(parsed)
if (!result.success) {
const error = result.error.issues
.map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`)
.join('; ')
return {
...this.baseItem(target),
exists: true,
status: 'invalid_schema',
bytes,
error: this.sanitizeText(error),
}
}
}
return {
...this.baseItem(target),
exists: true,
@ -345,7 +365,7 @@ export class DoctorService {
return {
...this.baseItem(target),
exists: false,
status: 'missing',
status: target.required ? 'missing' : 'not_configured',
bytes: 0,
}
}
@ -453,7 +473,7 @@ export class DoctorService {
scope: 'user' | 'project',
filePath: string,
): DoctorTarget {
return { id, label, kind: 'json', scope, filePath, protected: true }
return { id, label, kind: 'json', scope, filePath, protected: true, required: false }
}
private jsonlTarget(
@ -462,7 +482,7 @@ export class DoctorService {
scope: 'user' | 'project',
filePath: string,
): DoctorTarget {
return { id, label, kind: 'jsonl', scope, filePath, protected: true }
return { id, label, kind: 'jsonl', scope, filePath, protected: true, required: false }
}
private directoryTarget(
@ -471,7 +491,7 @@ export class DoctorService {
scope: 'user' | 'project',
filePath: string,
): DoctorTarget {
return { id, label, kind: 'directory', scope, filePath, protected: true }
return { id, label, kind: 'directory', scope, filePath, protected: true, required: false }
}
private withAlias(alias: string, filePath: string, root: string): string {

View File

@ -0,0 +1,38 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'node:fs'
import * as fsp from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { logForDiagnosticsNoPII } from './diagLogs.js'
let tmpDir: string
let originalPath: string | undefined
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'cc-haha-diag-writer-'))
originalPath = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
process.env.CLAUDE_CODE_DIAGNOSTICS_FILE = path.join(tmpDir, 'cli-diagnostics.jsonl')
})
afterEach(async () => {
if (originalPath === undefined) delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
else process.env.CLAUDE_CODE_DIAGNOSTICS_FILE = originalPath
await fsp.rm(tmpDir, { recursive: true, force: true })
})
describe('logForDiagnosticsNoPII', () => {
test('owns a per-process segment and rotates it without replacing a shared append target', async () => {
const basePath = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE!
const activePath = `${basePath}.${process.pid}.current.jsonl`
fs.writeFileSync(activePath, 'x'.repeat(1024 * 1024))
logForDiagnosticsNoPII('error', 'after_rotation', { code: 'ROTATED' })
expect(fs.existsSync(basePath)).toBe(false)
expect(fs.readFileSync(activePath, 'utf-8')).toContain('after_rotation')
const completedSegments = (await fsp.readdir(tmpDir)).filter((name) =>
name.startsWith(`cli-diagnostics.jsonl.${process.pid}.`) && !name.includes('.current.'),
)
expect(completedSegments).toHaveLength(1)
})
})

View File

@ -1,4 +1,4 @@
import { dirname } from 'path'
import { basename, dirname } from 'path'
import { getFsImplementation } from './fsOperations.js'
import { jsonStringify } from './slowOperations.js'
@ -11,6 +11,10 @@ type DiagnosticLogEntry = {
data: Record<string, unknown>
}
const MAX_SEGMENT_BYTES = 1024 * 1024
const MAX_COMPLETED_SEGMENTS = 4
let segmentSequence = 0
/**
* Logs diagnostic information to a logfile. This information is sent
* via the environment manager to session-ingress to monitor issues from
@ -29,8 +33,8 @@ export function logForDiagnosticsNoPII(
event: string,
data?: Record<string, unknown>,
): void {
const logFile = getDiagnosticLogFile()
if (!logFile) {
const baseLogFile = getDiagnosticLogFile()
if (!baseLogFile) {
return
}
@ -43,12 +47,15 @@ export function logForDiagnosticsNoPII(
const fs = getFsImplementation()
const line = jsonStringify(entry) + '\n'
const logFile = `${baseLogFile}.${process.pid}.current.jsonl`
try {
rotateOwnedSegmentIfNeeded(fs, baseLogFile, logFile, Buffer.byteLength(line))
fs.appendFileSync(logFile, line)
} catch {
// If append fails, try creating the directory first
try {
fs.mkdirSync(dirname(logFile))
rotateOwnedSegmentIfNeeded(fs, baseLogFile, logFile, Buffer.byteLength(line))
fs.appendFileSync(logFile, line)
} catch {
// Silently fail if logging is not possible
@ -56,6 +63,26 @@ export function logForDiagnosticsNoPII(
}
}
function rotateOwnedSegmentIfNeeded(
fs: ReturnType<typeof getFsImplementation>,
baseLogFile: string,
activeLogFile: string,
incomingBytes: number,
): void {
if (!fs.existsSync(activeLogFile)) return
if (fs.statSync(activeLogFile).size + incomingBytes <= MAX_SEGMENT_BYTES) return
segmentSequence += 1
const completedPath = `${baseLogFile}.${process.pid}.${Date.now()}-${segmentSequence}.jsonl`
fs.renameSync(activeLogFile, completedPath)
const prefix = `${basename(baseLogFile)}.${process.pid}.`
const completed = fs.readdirStringSync(dirname(baseLogFile))
.filter((name) => name.startsWith(prefix) && name.endsWith('.jsonl') && !name.endsWith('.current.jsonl'))
.sort()
for (const staleName of completed.slice(0, -MAX_COMPLETED_SEGMENTS)) {
fs.unlinkSync(`${dirname(baseLogFile)}/${staleName}`)
}
}
function getDiagnosticLogFile(): string | undefined {
return process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
}