/** * ConversationService — CLI subprocess manager * * Each desktop session owns one CLI subprocess. The subprocess talks back to * the desktop server over the SDK WebSocket bridge, while the desktop UI talks * to the server over its own client WebSocket. */ import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { ProviderService } from './providerService.js' import { sessionService } from './sessionService.js' import { diagnosticsService } from './diagnosticsService.js' import { buildClaudeCliArgs, resolveClaudeCliLauncher, } from '../../utils/desktopBundledCli.js' const MAX_CAPTURED_PROCESS_LINES = 80 const MAX_CAPTURED_SDK_MESSAGES = 40 const MAX_CAPTURED_SDK_SUMMARY = 20 const CONTROL_READY_POLL_MS = 50 type AttachmentRef = { type: 'file' | 'image' name?: string path?: string data?: string mimeType?: string } type SessionProcess = { proc: ReturnType outputCallbacks: Array<(msg: any) => void> workDir: string permissionMode: string sdkToken: string sdkSocket: { send(data: string): void } | null pendingOutbound: string[] startupPending: boolean startupExitCode: number | null stdoutLines: string[] stderrLines: string[] outputDrain: Promise sdkMessages: any[] initMessage: any | null pendingPermissionRequests: Map< string, { toolName: string input: Record permissionSuggestions?: unknown[] } > } type SessionStartOptions = { permissionMode?: string model?: string effort?: string thinking?: 'enabled' | 'adaptive' | 'disabled' providerId?: string | null } export class ConversationStartupError extends Error { constructor( message: string, readonly code: | 'WORKDIR_INVALID' | 'CLI_AUTH_REQUIRED' | 'CLI_SESSION_CONFLICT' | 'CLI_START_FAILED' | 'CLI_SPAWN_FAILED' | 'SESSION_DELETED', readonly retryable = false, ) { super(message) this.name = 'ConversationStartupError' } } export class ConversationService { private sessions = new Map() private deletedSessions = new Set() private providerService = new ProviderService() private buildSessionCliArgs( sessionId: string, sdkUrl: string, shouldResume: boolean, options?: SessionStartOptions, ): string[] { const dangerousMode = process.env.CLAUDE_DANGEROUS_MODE === '1' return this.resolveCliArgs([ '--print', '--verbose', '--sdk-url', sdkUrl, '--enable-auth-status', '--input-format', 'stream-json', '--output-format', 'stream-json', // Desktop chat depends on partial assistant deltas; without this the // server only sees the completed assistant message at turn end. '--include-partial-messages', ...(shouldResume ? ['--resume', sessionId] : ['--session-id', sessionId]), '--replay-user-messages', ...this.getRuntimeArgs(options), ...this.getPermissionArgs(options?.permissionMode, dangerousMode), ]) } async startSession( sessionId: string, workDir: string, sdkUrl: string, options?: SessionStartOptions, ): Promise { if (this.deletedSessions.has(sessionId)) { throw new ConversationStartupError( `Session was deleted before startup completed: ${sessionId}`, 'SESSION_DELETED', ) } if (this.sessions.has(sessionId)) return const launchInfo = await sessionService.getSessionLaunchInfo(sessionId) const shouldResume = !!launchInfo && launchInfo.transcriptMessageCount > 0 const shouldReplacePlaceholder = !!launchInfo && launchInfo.transcriptMessageCount === 0 if (this.deletedSessions.has(sessionId)) { throw new ConversationStartupError( `Session was deleted before startup completed: ${sessionId}`, 'SESSION_DELETED', ) } if (!fs.existsSync(workDir) || !fs.statSync(workDir).isDirectory()) { throw new ConversationStartupError( `Working directory does not exist or is not a directory: ${workDir}`, 'WORKDIR_INVALID', ) } if (shouldReplacePlaceholder) { await sessionService.clearSessionTranscript(sessionId, workDir) } const args = this.buildSessionCliArgs( sessionId, sdkUrl, shouldResume, options, ) console.log( `[ConversationService] Starting CLI for ${sessionId}, cwd: ${workDir} (process.cwd()=${process.cwd()}, CALLER_DIR will be pinned to workDir)`, ) // IMPORTANT (Bug#5): 必须覆盖子进程继承的 CALLER_DIR / PWD。 // preload.ts 顶层读 process.env.CALLER_DIR 并调用 process.chdir(CALLER_DIR)。 // 在 bundled 桌面端里,server sidecar 被 Tauri 从 cwd=/ 启动,claude-sidecar.ts // 在 server/cli 模式入口把 CALLER_DIR 默认设成 process.cwd()(即 '/'), // 随后这个 env 被完整继承到 Bun.spawn 的 CLI 子进程;即使这里显式传了 // cwd: workDir,CLI 子进程里 preload.ts 还是会 chdir('/'),结果把 // STATE.cwd / "Primary working directory" 打回根目录,IM 会话里 AI 感知的 // 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.ts // chdir 后落到正确目录。 // const childEnv = await this.buildChildEnv(workDir, sdkUrl, options) let proc: ReturnType try { proc = Bun.spawn(args, { cwd: workDir, env: childEnv, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }) } catch (spawnErr) { void diagnosticsService.recordEvent({ type: 'cli_spawn_failed', severity: 'error', sessionId, summary: spawnErr instanceof Error ? spawnErr.message : String(spawnErr), details: { workDir, permissionMode: options?.permissionMode || 'default', providerId: options?.providerId ?? null, model: options?.model ?? null, error: spawnErr, }, }) throw new ConversationStartupError( `Failed to spawn CLI in ${workDir}: ${ spawnErr instanceof Error ? spawnErr.message : String(spawnErr) }`, 'CLI_SPAWN_FAILED', ) } const session: SessionProcess = { proc, outputCallbacks: [], workDir, permissionMode: options?.permissionMode || 'default', sdkToken: this.getSdkTokenFromUrl(sdkUrl), sdkSocket: null, pendingOutbound: [], startupPending: true, startupExitCode: null, stdoutLines: [], stderrLines: [], outputDrain: Promise.resolve(), sdkMessages: [], initMessage: null, pendingPermissionRequests: new Map(), } this.sessions.set(sessionId, session) session.outputDrain = Promise.all([ this.readProcessOutputStream(sessionId, proc.stdout, 'stdout'), this.readProcessOutputStream(sessionId, proc.stderr, 'stderr'), ]).then(() => undefined) proc.exited.then((code) => { void this.handleProcessExit(sessionId, proc, code) }) const STARTUP_GRACE_MS = 3000 const earlyExitCode = await Promise.race([ proc.exited, new Promise((resolve) => setTimeout(() => resolve(null), STARTUP_GRACE_MS), ), ]) const startupExitCode = earlyExitCode ?? session.startupExitCode if (startupExitCode !== null) { await this.waitForProcessOutputDrain(session) const startupError = this.buildStartupError(sessionId, startupExitCode) this.sessions.delete(sessionId) if (this.clearStaleLock(sessionId)) { console.log( `[ConversationService] Removed stale lock for ${sessionId}, retrying...`, ) return this.startSession(sessionId, workDir, sdkUrl, options) } console.error( `[ConversationService] CLI exited with code ${startupExitCode} for ${sessionId}: ${startupError.message}`, ) void diagnosticsService.recordEvent({ type: 'cli_start_failed', severity: 'error', sessionId, summary: startupError.message, details: { code: startupError.code, exitCode: startupExitCode, retryable: startupError.retryable, workDir, permissionMode: options?.permissionMode || 'default', providerId: options?.providerId ?? null, model: options?.model ?? null, capturedOutput: this.buildCapturedProcessOutputDetail(session), sdkMessages: this.summarizeSdkMessages(session.sdkMessages), }, }) throw startupError } session.startupPending = false if (shouldReplacePlaceholder || !launchInfo) { await sessionService.appendSessionMetadata(sessionId, { workDir, customTitle: launchInfo?.customTitle ?? null, }) } console.log(`[ConversationService] CLI started successfully for ${sessionId}`) } onOutput(sessionId: string, callback: (msg: any) => void): void { const session = this.sessions.get(sessionId) if (session) { session.outputCallbacks.push(callback) } } clearOutputCallbacks(sessionId: string): void { const session = this.sessions.get(sessionId) if (session) { session.outputCallbacks = [] } } removeOutputCallback(sessionId: string, callback: (msg: any) => void): void { const session = this.sessions.get(sessionId) if (!session) return session.outputCallbacks = session.outputCallbacks.filter((entry) => entry !== callback) } getRecentSdkMessages(sessionId: string): any[] { return [...(this.sessions.get(sessionId)?.sdkMessages ?? [])] } getSessionInitMessage(sessionId: string): any | null { return this.sessions.get(sessionId)?.initMessage ?? null } sendMessage( sessionId: string, content: string, attachments?: AttachmentRef[], ): boolean { return this.sendSdkMessage(sessionId, { type: 'user', message: { role: 'user', content: this.buildUserContent(content, sessionId, attachments), }, parent_tool_use_id: null, session_id: '', }) } respondToPermission( sessionId: string, requestId: string, allowed: boolean, rule?: string, updatedInput?: Record, ): boolean { const session = this.sessions.get(sessionId) const pendingRequest = session?.pendingPermissionRequests.get(requestId) if (session) { session.pendingPermissionRequests.delete(requestId) } return this.sendSdkMessage(sessionId, { type: 'control_response', response: { subtype: 'success', request_id: requestId, response: allowed ? { behavior: 'allow', updatedInput: updatedInput ?? {}, ...(rule === 'always' && pendingRequest ? { updatedPermissions: [ ...normalizeSessionPermissionUpdates( pendingRequest.permissionSuggestions, pendingRequest.toolName, ), ], } : {}), } : { behavior: 'deny', message: 'User denied via UI' }, }, }) } setPermissionMode(sessionId: string, mode: string): boolean { return this.sendSdkMessage(sessionId, { type: 'control_request', request_id: crypto.randomUUID(), request: { subtype: 'set_permission_mode', mode, }, }) } sendInterrupt(sessionId: string): boolean { return this.sendSdkMessage(sessionId, { type: 'control_request', request_id: crypto.randomUUID(), request: { subtype: 'interrupt' }, }) } private isControlChannelReady(session: SessionProcess): boolean { return Boolean(session.sdkSocket) } private async waitForControlChannelReady( sessionId: string, timeoutMs: number, ): Promise { const startedAt = Date.now() while (Date.now() - startedAt < timeoutMs) { const session = this.sessions.get(sessionId) if (!session) { throw new Error('CLI session is not running') } if (this.isControlChannelReady(session)) { return } await new Promise((resolve) => setTimeout(resolve, CONTROL_READY_POLL_MS)) } throw new Error('Timed out waiting for CLI control channel to become ready') } async requestControl( sessionId: string, request: Record, timeoutMs = 10_000, ): Promise> { if (!this.sessions.has(sessionId)) { return Promise.reject(new Error('CLI session is not running')) } const startedAt = Date.now() await this.waitForControlChannelReady(sessionId, timeoutMs) const responseTimeoutMs = Math.max(1, timeoutMs - (Date.now() - startedAt)) const requestId = crypto.randomUUID() return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.removeOutputCallback(sessionId, handleOutput) reject(new Error(`Timed out waiting for ${String(request.subtype ?? 'control')} response`)) }, responseTimeoutMs) const finish = (fn: () => void) => { clearTimeout(timeout) this.removeOutputCallback(sessionId, handleOutput) fn() } const handleOutput = (msg: any) => { if ( msg?.type !== 'control_response' || msg.response?.request_id !== requestId ) { return } if (msg.response.subtype === 'error') { finish(() => reject(new Error(String(msg.response.error || 'Control request failed')))) return } finish(() => resolve( msg.response.response && typeof msg.response.response === 'object' ? msg.response.response as Record : {}, )) } this.onOutput(sessionId, handleOutput) const sent = this.sendSdkMessage(sessionId, { type: 'control_request', request_id: requestId, request, }) if (!sent) { finish(() => reject(new Error('CLI session is not running'))) } }) } hasSession(sessionId: string): boolean { return this.sessions.has(sessionId) } getSessionWorkDir(sessionId: string): string { const session = this.sessions.get(sessionId) return session?.workDir || '' } getSessionPermissionMode(sessionId: string): string { const session = this.sessions.get(sessionId) return session?.permissionMode || 'default' } authorizeSdkConnection( sessionId: string, token: string | null | undefined, ): boolean { const session = this.sessions.get(sessionId) return Boolean(session && token && token === session.sdkToken) } attachSdkConnection( sessionId: string, socket: { send(data: string): void }, ): boolean { const session = this.sessions.get(sessionId) if (!session) return false session.sdkSocket = socket while (session.pendingOutbound.length > 0) { const line = session.pendingOutbound.shift() if (line) { socket.send(line) } } return true } detachSdkConnection(sessionId: string): void { const session = this.sessions.get(sessionId) if (session) { session.sdkSocket = null } } handleSdkPayload(sessionId: string, rawPayload: string): void { const session = this.sessions.get(sessionId) if (!session) return const lines = rawPayload .split('\n') .map((line) => line.trim()) .filter(Boolean) for (const line of lines) { try { const msg = JSON.parse(line) session.sdkMessages.push(msg) if (session.sdkMessages.length > MAX_CAPTURED_SDK_MESSAGES) { session.sdkMessages.splice(0, session.sdkMessages.length - MAX_CAPTURED_SDK_MESSAGES) } const sdkError = this.extractSdkErrorEvent(msg) if (sdkError) { void diagnosticsService.recordEvent({ type: sdkError.type, severity: 'error', sessionId, summary: sdkError.summary, details: sdkError.details, }) } if (msg?.type === 'system' && msg.subtype === 'init') { session.initMessage = msg } if ( msg?.type === 'control_request' && msg.request?.subtype === 'can_use_tool' && typeof msg.request_id === 'string' ) { session.pendingPermissionRequests.set(msg.request_id, { toolName: typeof msg.request.tool_name === 'string' ? msg.request.tool_name : 'Unknown', input: msg.request.input && typeof msg.request.input === 'object' ? (msg.request.input as Record) : {}, permissionSuggestions: Array.isArray(msg.request.permission_suggestions) ? msg.request.permission_suggestions : undefined, }) } for (const cb of session.outputCallbacks) { cb(msg) } } catch { console.warn( `[ConversationService] Ignoring malformed SDK payload for ${sessionId}`, ) } } } stopSession(sessionId: string): void { const session = this.sessions.get(sessionId) if (session) { session.proc.kill() this.sessions.delete(sessionId) } } async stopSessionAndWait(sessionId: string, timeoutMs = 2_000): Promise { const session = this.sessions.get(sessionId) if (!session) return this.sessions.delete(sessionId) session.proc.kill() await Promise.race([ session.proc.exited.catch(() => undefined), new Promise((resolve) => setTimeout(resolve, timeoutMs)), ]) await this.waitForProcessOutputDrain(session, timeoutMs) } markSessionDeleted(sessionId: string): void { this.deletedSessions.add(sessionId) this.stopSession(sessionId) } unmarkSessionDeleted(sessionId: string): void { this.deletedSessions.delete(sessionId) } getActiveSessions(): string[] { return Array.from(this.sessions.keys()) } private async readProcessOutputStream( sessionId: string, stream: ReadableStream | null | undefined, streamName: 'stdout' | 'stderr', ): Promise { if (!stream) return const reader = stream.getReader() const decoder = new TextDecoder() try { while (true) { const { done, value } = await reader.read() if (done) break const text = decoder.decode(value, { stream: true }) if (!text.trim()) continue const session = this.sessions.get(sessionId) if (session) { for (const line of text .split('\n') .map((entry) => entry.trim()) .filter(Boolean)) { const lines = streamName === 'stderr' ? session.stderrLines : session.stdoutLines lines.push(this.redactProcessOutput(line)) if (lines.length > MAX_CAPTURED_PROCESS_LINES) { lines.splice(0, lines.length - MAX_CAPTURED_PROCESS_LINES) } } } const logLine = this.redactProcessOutput(text.trim()) if (streamName === 'stderr') { console.error(`[CLI:${sessionId}:stderr] ${logLine}`) } else { console.log(`[CLI:${sessionId}:stdout] ${logLine}`) } } } catch { // Process output read failures should not kill the session. } } private async waitForProcessOutputDrain( session: SessionProcess, timeoutMs = 250, ): Promise { const outputDrain = session.outputDrain ?? Promise.resolve() await Promise.race([ outputDrain.catch(() => undefined), new Promise((resolve) => setTimeout(resolve, timeoutMs)), ]) } private sendSdkMessage( sessionId: string, payload: Record, ): boolean { const session = this.sessions.get(sessionId) if (!session) return false const line = JSON.stringify(payload) + '\n' if (session.sdkSocket) { session.sdkSocket.send(line) } else { session.pendingOutbound.push(line) } return true } private async handleProcessExit( sessionId: string, proc: SessionProcess['proc'], code: number, ): Promise { console.log( `[ConversationService] CLI process for ${sessionId} exited with code ${code}`, ) const activeSession = this.sessions.get(sessionId) if (activeSession?.proc === proc) { if (activeSession.startupPending) { activeSession.startupExitCode = code return } await this.waitForProcessOutputDrain(activeSession) const exitError = this.buildRuntimeExitMessage(sessionId, code) void diagnosticsService.recordEvent({ type: 'cli_runtime_exit', severity: 'error', sessionId, summary: exitError, details: { exitCode: code, workDir: activeSession.workDir, permissionMode: activeSession.permissionMode, capturedOutput: this.buildCapturedProcessOutputDetail(activeSession), sdkMessages: this.summarizeSdkMessages(activeSession.sdkMessages), }, }) for (const cb of activeSession.outputCallbacks) { cb({ type: 'result', subtype: 'error', is_error: true, result: exitError, usage: { input_tokens: 0, output_tokens: 0 }, session_id: sessionId, }) } this.sessions.delete(sessionId) } } private getPermissionArgs( mode: string | undefined, dangerousMode: boolean, ): string[] { if (dangerousMode) { return ['--dangerously-skip-permissions'] } const resolvedMode = mode || 'default' if (resolvedMode === 'bypassPermissions') { return ['--dangerously-skip-permissions'] } const args = ['--permission-mode', resolvedMode] return args } private getRuntimeArgs(options: SessionStartOptions | undefined): string[] { const args: string[] = [] if (options?.model) { args.push('--model', options.model) } if (options?.effort) { args.push('--effort', options.effort) } if (options?.thinking) { args.push('--thinking', options.thinking) } return args } private async buildChildEnv( workDir: string, sdkUrl?: string, options?: SessionStartOptions, ): Promise> { // Provider isolation: when Desktop has its own provider config/index, // strip inherited provider env vars so the child CLI reads fresh values // from ~/.claude/cc-haha/settings.json instead of stale process.env. // // If the user never configured a Desktop provider and only launched the // app/server with ANTHROPIC_* env vars, keep those env vars so Windows // dev-mode and env-only setups can still authenticate successfully. const PROVIDER_ENV_KEYS = [ 'ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', 'CC_HAHA_SEND_DISABLED_THINKING', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS', ] as const const cleanEnv = { ...process.env } delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN if (this.shouldStripInheritedProviderEnv(options?.providerId)) { for (const key of PROVIDER_ENV_KEYS) { delete cleanEnv[key] } } let desktopServerUrl: string | undefined if (sdkUrl) { try { const parsed = new URL(sdkUrl) desktopServerUrl = `http://${parsed.host}` } catch { desktopServerUrl = undefined } } const explicitProviderEnv = typeof options?.providerId === 'string' ? await this.providerService.getProviderRuntimeEnv(options.providerId) : null if (explicitProviderEnv && options?.model?.trim()) { explicitProviderEnv.ANTHROPIC_MODEL = options.model.trim() } const cliDiagnosticsPath = diagnosticsService.getCliDiagnosticsPath() try { fs.mkdirSync(path.dirname(cliDiagnosticsPath), { recursive: true }) } catch { // Diagnostics must never block session startup. } return { ...cleanEnv, CLAUDE_CODE_ENABLE_TASKS: '1', CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: '1', CLAUDE_CODE_DIAGNOSTICS_FILE: cliDiagnosticsPath, CALLER_DIR: workDir, PWD: workDir, ...(sdkUrl ? { CC_HAHA_COMPUTER_USE_HOST_BUNDLE_ID: 'com.claude-code-haha.desktop' } : {}), ...(desktopServerUrl ? { CC_HAHA_DESKTOP_SERVER_URL: desktopServerUrl } : {}), ...(sdkUrl ? { CC_HAHA_DESKTOP_AWAIT_MCP: '1', CC_HAHA_DESKTOP_AWAIT_MCP_TIMEOUT_MS: '5000', } : {}), // Tell the CLI entrypoint to skip project .env loading. Provider env // should come from Desktop-managed config or inherited launch env, not // be reintroduced from the repo's .env file. CC_HAHA_SKIP_DOTENV: '1', ...(explicitProviderEnv ? { CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1' } : {}), // "官方" 模式 (cc-haha/settings.json 没 provider env) 下,把 CLI 标记为 // managed-OAuth,让它忽略外部 ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN // 残留、只走用户 /login 的 OAuth token。自定义 provider 模式绝不能设, // 否则 CLI 会忽略 provider 的 AUTH_TOKEN、错误地走 OAuth 打到第三方 // endpoint。详见 src/utils/auth.ts isManagedOAuthContext()。 ...(explicitProviderEnv ?? {}), ...(this.shouldMarkManagedOAuth(options?.providerId) ? await this.buildOfficialOAuthEnv() : {}), } } /** * 官方模式下构造 CLI 子进程的 auth env: * - CLAUDE_CODE_ENTRYPOINT=claude-desktop 让 CLI 忽略外部残留 ANTHROPIC_* env * - 如果 haha 自管的 oauth.json 里有可用 token,注入 CLAUDE_CODE_OAUTH_TOKEN * 让 CLI 直接拿 env 里的 token,不碰 Keychain,绕开 macOS ACL 静默拒绝 * (这是 DMG 安装 .app 后 403 "Request not allowed" 的唯一根治方案) */ private async buildOfficialOAuthEnv(): Promise> { const env: Record = { CLAUDE_CODE_ENTRYPOINT: 'claude-desktop', } try { // deferred import: avoids instantiating the OAuth singleton on every // ConversationService construction — only loaded when official mode hits. const { hahaOAuthService } = await import('./hahaOAuthService.js') const token = await hahaOAuthService.ensureFreshAccessToken() if (token) { env.CLAUDE_CODE_OAUTH_TOKEN = token } } catch (err) { console.error( '[conversationService] ensureFreshAccessToken failed:', err instanceof Error ? err.message : err, ) } return env } private shouldStripInheritedProviderEnv(providerId?: string | null): boolean { if (providerId !== undefined) { return true } const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') const ccHahaDir = path.join(configDir, 'cc-haha') const providersIndexPath = path.join(ccHahaDir, 'providers.json') const settingsPath = path.join(ccHahaDir, 'settings.json') if (fs.existsSync(providersIndexPath)) { return true } try { const raw = fs.readFileSync(settingsPath, 'utf-8') const parsed = JSON.parse(raw) as { env?: Record } const env = parsed.env ?? {} return [ 'ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', 'CC_HAHA_SEND_DISABLED_THINKING', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS', ].some((key) => typeof env[key] === 'string' && env[key]!.trim().length > 0) } catch { return false } } /** * 只有当用户处于"官方"模式(没有激活任何自定义 provider)时,才把 CLI 标记为 * managed-OAuth。激活自定义 provider 时 settings.json 里有 ANTHROPIC_AUTH_TOKEN; * 这种情况下 CLI 必须按 token 路径走第三方 endpoint,不能被 managed 规则 * 强制切 OAuth。 * * 默认 (读不到 settings.json) 按"官方"处理 — 即使用户从未用过 cc-haha * provider 管理,也希望官方 OAuth 能正常工作。 */ private shouldMarkManagedOAuth(providerId?: string | null): boolean { if (providerId === null) { return true } if (typeof providerId === 'string') { return false } const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') const settingsPath = path.join(configDir, 'cc-haha', 'settings.json') try { const raw = fs.readFileSync(settingsPath, 'utf-8') const parsed = JSON.parse(raw) as { env?: Record } const env = parsed.env ?? {} const hasProviderEnv = [ 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', ].some( (key) => typeof env[key] === 'string' && env[key]!.trim().length > 0, ) return !hasProviderEnv } catch { return true } } private resolveCliArgs(baseArgs: string[]): string[] { const launcher = resolveClaudeCliLauncher({ cliPath: process.env.CLAUDE_CLI_PATH, execPath: process.execPath, }) if (!launcher) { if (process.platform === 'win32') { return [ process.execPath, '--preload', path.resolve(import.meta.dir, '../../../preload.ts'), path.resolve(import.meta.dir, '../../entrypoints/cli.tsx'), ...baseArgs, ] } return [path.resolve(import.meta.dir, '../../../bin/claude-haha'), ...baseArgs] } return buildClaudeCliArgs(launcher, baseArgs, process.env.CLAUDE_APP_ROOT) } private clearStaleLock(sessionId: string): boolean { const lockDir = path.join( process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), '.lock', ) const lockFile = path.join(lockDir, sessionId) if (!fs.existsSync(lockFile)) { return false } try { fs.unlinkSync(lockFile) return true } catch { return false } } private buildStartupError( sessionId: string, exitCode: number, ): ConversationStartupError { const session = this.sessions.get(sessionId) const capturedOutput = this.buildCapturedProcessOutputDetail(session) const recentMessages = session?.sdkMessages ?? [] const resultMessage = [...recentMessages] .reverse() .find((msg) => msg?.type === 'result' && msg.is_error) const assistantApiError = [...recentMessages] .reverse() .find((msg) => this.isAssistantApiErrorMessage(msg)) const authStatus = [...recentMessages] .reverse() .find((msg) => msg?.type === 'auth_status') const detail = this.extractStartupDetail(resultMessage) || this.extractAssistantApiErrorDetail(assistantApiError) || this.extractStartupDetail(authStatus) || capturedOutput if ( /(not logged in|run \/login|sign in again|login required|unauthenticated|logged_out)/i.test( detail, ) ) { return new ConversationStartupError( 'Desktop chat could not start because Claude CLI is not authenticated. Run `./bin/claude-haha /login` or provide valid API credentials, then retry.', 'CLI_AUTH_REQUIRED', ) } if (/session id .*already in use/i.test(detail)) { return new ConversationStartupError( `Session ${sessionId} is already in use by another CLI process or transcript.`, 'CLI_SESSION_CONFLICT', true, ) } const normalizedDetail = detail.trim() return new ConversationStartupError( normalizedDetail ? `CLI exited during startup (code ${exitCode}): ${normalizedDetail}` : `CLI exited during startup with code ${exitCode}; no CLI stderr/stdout or SDK error payload was captured before exit.`, 'CLI_START_FAILED', true, ) } private buildRuntimeExitMessage(sessionId: string, exitCode: number): string { const session = this.sessions.get(sessionId) const capturedOutput = this.buildCapturedProcessOutputDetail(session) const recentMessages = session?.sdkMessages ?? [] const resultMessage = [...recentMessages] .reverse() .find((msg) => msg?.type === 'result' && msg.is_error) const assistantApiError = [...recentMessages] .reverse() .find((msg) => this.isAssistantApiErrorMessage(msg)) const authStatus = [...recentMessages] .reverse() .find((msg) => msg?.type === 'auth_status') const detail = this.extractStartupDetail(resultMessage) || this.extractAssistantApiErrorDetail(assistantApiError) || this.extractStartupDetail(authStatus) || capturedOutput return detail ? `CLI process exited unexpectedly (code ${exitCode}): ${detail}` : `CLI process exited unexpectedly with code ${exitCode}; no CLI stderr/stdout or SDK error payload was captured before exit.` } private buildCapturedProcessOutputDetail( session: SessionProcess | undefined, ): string { if (!session) return '' const stderrText = (session.stderrLines ?? []).join('\n').trim() const stdoutText = (session.stdoutLines ?? []).join('\n').trim() if (stderrText && stdoutText) { return `stderr:\n${stderrText}\nstdout:\n${stdoutText}` } return stderrText || stdoutText } private redactProcessOutput(line: string): string { return line .replace(/(ANTHROPIC_(?:API_KEY|AUTH_TOKEN)\s*[:=]\s*)[^\s,;]+/gi, '$1[REDACTED]') .replace(/((?:api[_-]?key|auth[_-]?token|access[_-]?token)\s*[:=]\s*)[^\s,;]+/gi, '$1[REDACTED]') .replace(/(Bearer\s+)[A-Za-z0-9._~+/-]+/gi, '$1[REDACTED]') } private extractStartupDetail(message: any): string { if (!message) return '' if (typeof message.result === 'string') return message.result if (typeof message.status === 'string') return message.status if (typeof message.message === 'string') return message.message if (Array.isArray(message?.errors)) { return message.errors .filter((value: unknown): value is string => typeof value === 'string') .join('\n') } return '' } private isAssistantApiErrorMessage(message: any): boolean { return ( message?.type === 'assistant' && (message.isApiErrorMessage === true || typeof message.error === 'string') ) } private extractAssistantApiErrorDetail(message: any): string { if (!this.isAssistantApiErrorMessage(message)) return '' const text = this.extractAssistantText(message) const error = typeof message.error === 'string' ? message.error : '' if (text && error) return `${error}: ${text}` return text || error } private extractAssistantText(message: any): string { const content = message?.message?.content if (!Array.isArray(content)) return '' const textBlock = content.find( (block: unknown): block is { type: string; text: string } => !!block && typeof block === 'object' && (block as { type?: unknown }).type === 'text' && typeof (block as { text?: unknown }).text === 'string', ) return textBlock?.text || '' } private extractSdkErrorEvent(message: any): { type: string summary: string details: Record } | null { if (this.isAssistantApiErrorMessage(message)) { const summary = this.redactProcessOutput( this.extractAssistantApiErrorDetail(message) || 'Assistant API error', ) return { type: 'sdk_api_error', summary, details: { sdkType: message.type, error: typeof message.error === 'string' ? message.error : undefined, isApiErrorMessage: message.isApiErrorMessage === true, messageText: this.extractAssistantText(message) ? this.redactProcessOutput(this.extractAssistantText(message)) : undefined, errorDetails: typeof message.errorDetails === 'string' ? this.redactProcessOutput(message.errorDetails) : undefined, }, } } if (message?.type === 'result' && message.is_error) { const summary = this.redactProcessOutput( this.extractStartupDetail(message) || 'SDK result error', ) return { type: 'sdk_result_error', summary, details: { sdkType: message.type, subtype: message.subtype, isError: true, result: typeof message.result === 'string' ? this.redactProcessOutput(message.result) : undefined, status: typeof message.status === 'string' ? this.redactProcessOutput(message.status) : undefined, usage: message.usage, }, } } return null } private summarizeSdkMessages(messages: any[]): unknown[] { return messages.slice(-MAX_CAPTURED_SDK_SUMMARY).map((message) => { if (!message || typeof message !== 'object') { return message } 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 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, } }) } private buildUserContent( content: string, sessionId: string, attachments?: AttachmentRef[], ): Array> { const prefix = this.materializeAttachments(sessionId, attachments) const trimmed = content.trim() const text = prefix ? `${prefix}${trimmed || 'Please analyze the attached files.'}`.trim() : trimmed return [{ type: 'text', text }] } private materializeAttachments( sessionId: string, attachments?: AttachmentRef[], ): string { if (!attachments || attachments.length === 0) { return '' } const uploadDir = path.join( process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'uploads', sessionId, ) fs.mkdirSync(uploadDir, { recursive: true }) const savedPaths: string[] = [] for (const attachment of attachments) { if (attachment.path) { savedPaths.push(attachment.path) continue } if (!attachment.data) continue const payload = this.parseAttachmentData(attachment.data) if (!payload) continue const ext = this.getAttachmentExtension(attachment) const fileName = this.sanitizeAttachmentName(attachment.name, attachment.type, ext) const outPath = path.join(uploadDir, `${crypto.randomUUID()}-${fileName}`) fs.writeFileSync(outPath, payload) savedPaths.push(outPath) } if (savedPaths.length === 0) { return '' } return savedPaths.map((filePath) => `@"${filePath}"`).join(' ') + ' ' } private parseAttachmentData(data: string): Buffer | null { const match = data.match(/^data:.*?;base64,(.*)$/) const encoded = match ? match[1] : data try { return Buffer.from(encoded, 'base64') } catch { return null } } private getAttachmentExtension(attachment: AttachmentRef): string { const byName = attachment.name?.match(/\.([a-z0-9]+)$/i)?.[1] if (byName) return byName const byMime = attachment.mimeType?.split('/')[1]?.split('+')[0] if (byMime) return byMime return attachment.type === 'image' ? 'png' : 'bin' } private sanitizeAttachmentName( name: string | undefined, type: AttachmentRef['type'], ext: string, ): string { const fallback = `${type}-attachment.${ext}` const normalized = (name || fallback).replace(/[^a-zA-Z0-9._-]/g, '_') return normalized || fallback } private getSdkTokenFromUrl(sdkUrl: string): string { const url = new URL(sdkUrl) return url.searchParams.get('token') || '' } } function normalizeSessionPermissionUpdates( suggestions: unknown[] | undefined, toolName: string, ) { if (Array.isArray(suggestions) && suggestions.length > 0) { return suggestions.map((suggestion) => { if (!suggestion || typeof suggestion !== 'object') { return suggestion } return { ...suggestion, destination: 'session', } }) } return [ { type: 'addRules', rules: [{ toolName }], behavior: 'allow', destination: 'session', }, ] } export const conversationService = new ConversationService()