Download src/server/services/conversationService.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 51.9 kB
-
https://huggingface.co/chenbhao/codev/resolve/main/src/server/services/conversationService.ts
- Command line
-
hf download hf://chenbhao/codev/src/server/services/conversationService.ts
-
curl -L -o conversationService.ts https://huggingface.co/chenbhao/codev/resolve/main/src/server/services/conversationService.ts
51.9 kB
| /** | |
| * 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 { | |
| OPENAI_CODEX_OAUTH_FILE_ENV_KEY, | |
| OPENAI_OAUTH_PROVIDER_ENV_KEY, | |
| } from './openaiOfficialProvider.js' | |
| import { sessionService } from './sessionService.js' | |
| import { diagnosticsService } from './diagnosticsService.js' | |
| import { | |
| isMaterializedWorktreeLaunch, | |
| prepareSessionWorkspace, | |
| shouldCreateWorktreeForSessionLaunch, | |
| type PreparedSessionWorkspace, | |
| } from './repositoryLaunchService.js' | |
| import { | |
| buildClaudeCliArgs, | |
| resolveClaudeCliLauncher, | |
| } from '../../utils/desktopBundledCli.js' | |
| import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' | |
| import { findCanonicalGitRoot } from '../../utils/git.js' | |
| import { sanitizePath } from '../../utils/path.js' | |
| import { getProcessEnvWithTerminalShellEnvironment } from '../../utils/terminalShellEnvironment.js' | |
| import { attributionHeaderEnvForModel } from './attributionHeaderPolicy.js' | |
| import { buildNetworkEnvironment, loadNetworkSettings } from './networkSettings.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 | |
| const AUTO_MEMORY_DIRNAME = 'memory' | |
| type AttachmentRef = { | |
| type: 'file' | 'image' | |
| name?: string | |
| path?: string | |
| data?: string | |
| mimeType?: string | |
| isDirectory?: boolean | |
| } | |
| type SessionProcess = { | |
| proc: ReturnType<typeof Bun.spawn> | |
| 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<void> | |
| sdkMessages: any[] | |
| initMessage: any | null | |
| pendingPermissionRequests: Map< | |
| string, | |
| { | |
| toolName: string | |
| input: Record<string, unknown> | |
| 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<string, SessionProcess>() | |
| private deletedSessions = new Set<string>() | |
| private providerService = new ProviderService() | |
| private buildSessionCliArgs( | |
| sessionId: string, | |
| sdkUrl: string, | |
| shouldResume: boolean, | |
| options?: SessionStartOptions, | |
| repository?: PreparedSessionWorkspace['repository'], | |
| ): string[] { | |
| const dangerousMode = process.env.CLAUDE_DANGEROUS_MODE === '1' | |
| const worktreeArgs = | |
| !shouldResume && repository?.worktree | |
| ? [ | |
| '--worktree', | |
| repository.worktreeSlug || repository.worktreeBranch || repository.branch, | |
| '--worktree-base-ref', | |
| repository.baseRef, | |
| ] | |
| : [] | |
| 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]), | |
| ...worktreeArgs, | |
| '--replay-user-messages', | |
| ...this.getRuntimeArgs(options), | |
| ...this.getPermissionArgs(options?.permissionMode, dangerousMode), | |
| ]) | |
| } | |
| async startSession( | |
| sessionId: string, | |
| workDir: string, | |
| sdkUrl: string, | |
| options?: SessionStartOptions, | |
| ): Promise<void> { | |
| 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 | |
| const shouldCreateWorktree = | |
| !!launchInfo && shouldCreateWorktreeForSessionLaunch(launchInfo) | |
| const hasMaterializedWorktree = | |
| !!launchInfo && isMaterializedWorktreeLaunch(launchInfo) | |
| 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) | |
| } | |
| let launchWorkDir = workDir | |
| let launchRepository = launchInfo?.repository | |
| if (shouldCreateWorktree && launchRepository?.worktree) { | |
| launchWorkDir = launchRepository.requestedWorkDir || launchRepository.repoRoot || workDir | |
| } else if (!shouldResume && launchRepository && !hasMaterializedWorktree) { | |
| const preparedWorkspace = await prepareSessionWorkspace( | |
| workDir, | |
| { | |
| branch: launchRepository.branch, | |
| worktree: false, | |
| }, | |
| sessionId, | |
| ) | |
| launchWorkDir = preparedWorkspace.workDir | |
| launchRepository = preparedWorkspace.repository | |
| } | |
| if (!shouldCreateWorktree && launchRepository?.worktree) { | |
| launchRepository = { | |
| ...launchRepository, | |
| worktree: false, | |
| } | |
| } | |
| if (!fs.existsSync(launchWorkDir) || !fs.statSync(launchWorkDir).isDirectory()) { | |
| throw new ConversationStartupError( | |
| `Working directory does not exist or is not a directory: ${launchWorkDir}`, | |
| 'WORKDIR_INVALID', | |
| ) | |
| } | |
| const args = this.buildSessionCliArgs( | |
| sessionId, | |
| sdkUrl, | |
| shouldResume, | |
| options, | |
| launchRepository, | |
| ) | |
| console.log( | |
| `[ConversationService] Starting CLI for ${sessionId}, cwd: ${launchWorkDir} (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(launchWorkDir, sdkUrl, options) | |
| let proc: ReturnType<typeof Bun.spawn> | |
| try { | |
| proc = Bun.spawn(args, { | |
| cwd: launchWorkDir, | |
| 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 ${launchWorkDir}: ${ | |
| spawnErr instanceof Error ? spawnErr.message : String(spawnErr) | |
| }`, | |
| 'CLI_SPAWN_FAILED', | |
| ) | |
| } | |
| const session: SessionProcess = { | |
| proc, | |
| outputCallbacks: [], | |
| workDir: launchWorkDir, | |
| 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<null>((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: launchWorkDir, | |
| 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: launchWorkDir, | |
| customTitle: launchInfo?.customTitle ?? null, | |
| repository: launchRepository, | |
| }) | |
| } | |
| 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<string, unknown>, | |
| ): 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, | |
| }, | |
| }) | |
| } | |
| setMaxThinkingTokens(sessionId: string, maxThinkingTokens: number | null): boolean { | |
| return this.sendSdkMessage(sessionId, { | |
| type: 'control_request', | |
| request_id: crypto.randomUUID(), | |
| request: { | |
| subtype: 'set_max_thinking_tokens', | |
| max_thinking_tokens: maxThinkingTokens, | |
| }, | |
| }) | |
| } | |
| setMaxThinkingTokensForActiveSessions(maxThinkingTokens: number | null): number { | |
| let sent = 0 | |
| for (const sessionId of this.getActiveSessions()) { | |
| if (this.setMaxThinkingTokens(sessionId, maxThinkingTokens)) { | |
| sent += 1 | |
| } | |
| } | |
| return sent | |
| } | |
| 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<void> { | |
| 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<string, unknown>, | |
| timeoutMs = 10_000, | |
| ): Promise<Record<string, unknown>> { | |
| 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<string, unknown> | |
| : {}, | |
| )) | |
| } | |
| 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 || '' | |
| } | |
| updateSessionWorkDir(sessionId: string, workDir: string): void { | |
| const session = this.sessions.get(sessionId) | |
| if (!session || !workDir.trim()) return | |
| session.workDir = 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<string, unknown>) | |
| : {}, | |
| 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<void> { | |
| 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<void>((resolve) => setTimeout(resolve, timeoutMs)), | |
| ]) | |
| await this.waitForProcessOutputDrain(session, timeoutMs) | |
| } | |
| markSessionDeleted(sessionId: string): void { | |
| this.deletedSessions.add(sessionId) | |
| this.stopSession(sessionId) | |
| } | |
| markSessionsDeleted(sessionIds: string[]): void { | |
| for (const sessionId of sessionIds) { | |
| this.markSessionDeleted(sessionId) | |
| } | |
| } | |
| unmarkSessionDeleted(sessionId: string): void { | |
| this.deletedSessions.delete(sessionId) | |
| } | |
| unmarkSessionsDeleted(sessionIds: string[]): void { | |
| for (const sessionId of sessionIds) { | |
| this.unmarkSessionDeleted(sessionId) | |
| } | |
| } | |
| getActiveSessions(): string[] { | |
| return Array.from(this.sessions.keys()) | |
| } | |
| private async readProcessOutputStream( | |
| sessionId: string, | |
| stream: ReadableStream | null | undefined, | |
| streamName: 'stdout' | 'stderr', | |
| ): Promise<void> { | |
| 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<void> { | |
| const outputDrain = session.outputDrain ?? Promise.resolve() | |
| await Promise.race([ | |
| outputDrain.catch(() => undefined), | |
| new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)), | |
| ]) | |
| } | |
| private sendSdkMessage( | |
| sessionId: string, | |
| payload: Record<string, unknown>, | |
| ): 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<void> { | |
| console.log( | |
| `[ConversationService] CLI process for ${sessionId} exited with code ${code}`, | |
| ) | |
| const activeSession = this.sessions.get(sessionId) | |
| if (activeSession?.proc === proc) { | |
| if (activeSession.startupPending) { | |
| activeSession.startupExitCode = code | |
| return | |
| } | |
| await this.waitForProcessOutputDrain(activeSession) | |
| const exitError = this.buildRuntimeExitMessage(sessionId, code) | |
| void diagnosticsService.recordEvent({ | |
| type: 'cli_runtime_exit', | |
| severity: 'error', | |
| sessionId, | |
| summary: exitError, | |
| details: { | |
| exitCode: code, | |
| workDir: activeSession.workDir, | |
| permissionMode: activeSession.permissionMode, | |
| capturedOutput: this.buildCapturedProcessOutputDetail(activeSession), | |
| sdkMessages: this.summarizeSdkMessages(activeSession.sdkMessages), | |
| }, | |
| }) | |
| for (const cb of activeSession.outputCallbacks) { | |
| cb({ | |
| type: 'result', | |
| 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 readCliGlobalConfig(): { | |
| authProvider?: 'anthropic' | 'openai' | 'openrouter' | 'local' | 'opencode' | 'nvidia' | |
| openCodeApiKey?: string | |
| openCodeModelName?: string | |
| nvidiaApiKey?: string | |
| openRouterApiKey?: string | |
| localBaseUrl?: string | |
| localModelName?: string | |
| } | null { | |
| const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') | |
| const configPath = path.join(configDir, '.claude.json') | |
| try { | |
| const raw = fs.readFileSync(configPath, 'utf-8') | |
| return JSON.parse(raw) as ReturnType<typeof this.readCliGlobalConfig> | |
| } catch { | |
| return null | |
| } | |
| } | |
| private buildCliProviderEnv( | |
| authProvider: 'opencode' | 'nvidia' | 'openrouter' | 'local', | |
| config: NonNullable<ReturnType<typeof this.readCliGlobalConfig>>, | |
| ): Record<string, string> { | |
| const env: Record<string, string> = {} | |
| if (authProvider === 'opencode') { | |
| if (config.openCodeApiKey) { | |
| env.ANTHROPIC_API_KEY = config.openCodeApiKey | |
| } | |
| if (config.openCodeModelName) { | |
| env.ANTHROPIC_MODEL = config.openCodeModelName | |
| } | |
| // opencode uses custom fetch override, no base URL override needed | |
| } else if (authProvider === 'nvidia') { | |
| if (config.nvidiaApiKey) { | |
| env.ANTHROPIC_API_KEY = config.nvidiaApiKey | |
| } | |
| env.ANTHROPIC_BASE_URL = 'https://integrate.api.nvidia.com/v1' | |
| } else if (authProvider === 'openrouter') { | |
| if (config.openRouterApiKey) { | |
| env.ANTHROPIC_API_KEY = config.openRouterApiKey | |
| } | |
| env.ANTHROPIC_BASE_URL = 'https://openrouter.ai/api/v1' | |
| } else if (authProvider === 'local') { | |
| if (config.localBaseUrl) { | |
| env.ANTHROPIC_BASE_URL = config.localBaseUrl | |
| } | |
| if (config.localModelName) { | |
| env.ANTHROPIC_MODEL = config.localModelName | |
| } | |
| } | |
| return env | |
| } | |
| private async buildChildEnv( | |
| workDir: string, | |
| sdkUrl?: string, | |
| options?: SessionStartOptions, | |
| ): Promise<Record<string, string>> { | |
| // 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_ATTRIBUTION_HEADER', | |
| 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS', | |
| OPENAI_OAUTH_PROVIDER_ENV_KEY, | |
| OPENAI_CODEX_OAUTH_FILE_ENV_KEY, | |
| ] as const | |
| const cleanEnv = await getProcessEnvWithTerminalShellEnvironment() | |
| 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 | |
| const networkEnv = buildNetworkEnvironment(await loadNetworkSettings()) | |
| if (explicitProviderEnv && options?.model?.trim()) { | |
| explicitProviderEnv.ANTHROPIC_MODEL = options.model.trim() | |
| } | |
| // Check for CLI-managed authProvider (opencode, nvidia, openrouter, local) | |
| // These are configured via CLI's /login command and stored in ~/.claude.json | |
| const cliConfig = this.readCliGlobalConfig() | |
| const cliAuthProvider = cliConfig?.authProvider | |
| const isCliManagedProvider = | |
| cliAuthProvider !== undefined && | |
| cliAuthProvider !== 'anthropic' && | |
| cliAuthProvider !== 'openai' | |
| const cliProviderEnv = isCliManagedProvider | |
| ? this.buildCliProviderEnv( | |
| cliAuthProvider as 'opencode' | 'nvidia' | 'openrouter' | 'local', | |
| cliConfig!, | |
| ) | |
| : null | |
| const attributionHeaderEnv = attributionHeaderEnvForModel( | |
| options?.model?.trim() || | |
| cliProviderEnv?.ANTHROPIC_MODEL || | |
| explicitProviderEnv?.ANTHROPIC_MODEL || | |
| cleanEnv.ANTHROPIC_MODEL, | |
| ) | |
| 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, | |
| CLAUDE_COWORK_MEMORY_PATH_OVERRIDE: this.resolveDesktopAutoMemoryPath(workDir), | |
| 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 ?? {}), | |
| // CLI-managed provider env (opencode/nvidia/openrouter/local from ~/.claude.json) | |
| // takes precedence over cc-haha provider env | |
| ...(cliProviderEnv ?? {}), | |
| ...networkEnv, | |
| // Skip Desktop OAuth when using CLI-managed providers (they handle their own auth) | |
| ...(isCliManagedProvider | |
| ? {} | |
| : this.shouldMarkManagedOAuth(options?.providerId) | |
| ? await this.buildOfficialOAuthEnv() | |
| : {}), | |
| ...attributionHeaderEnv, | |
| } | |
| } | |
| private resolveDesktopAutoMemoryPath(workDir: string): string { | |
| const memoryProjectRoot = fs.existsSync(workDir) | |
| ? findCanonicalGitRoot(workDir) ?? workDir | |
| : workDir | |
| return ( | |
| path.join( | |
| getClaudeConfigHomeDir(), | |
| 'projects', | |
| sanitizePath(memoryProjectRoot), | |
| AUTO_MEMORY_DIRNAME, | |
| ) + path.sep | |
| ).normalize('NFC') | |
| } | |
| /** | |
| * 官方模式下构造 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<Record<string, string>> { | |
| const env: Record<string, string> = { | |
| 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 | |
| } | |
| // Check ~/.claude.json first - if CLI has a non-anthropic/non-openai authProvider, | |
| // strip inherited env so CLI reads from its own config | |
| const cliConfig = this.readCliGlobalConfig() | |
| if (cliConfig?.authProvider && cliConfig.authProvider !== 'anthropic' && cliConfig.authProvider !== 'openai') { | |
| 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<string, string> } | |
| 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_ATTRIBUTION_HEADER', | |
| 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS', | |
| OPENAI_OAUTH_PROVIDER_ENV_KEY, | |
| OPENAI_CODEX_OAUTH_FILE_ENV_KEY, | |
| ].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 | |
| } | |
| // Check ~/.claude.json first - if CLI has a non-anthropic/non-openai authProvider, | |
| // it manages its own auth, skip Desktop OAuth injection | |
| const cliConfig = this.readCliGlobalConfig() | |
| if (cliConfig?.authProvider && cliConfig.authProvider !== 'anthropic' && cliConfig.authProvider !== 'openai') { | |
| 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<string, string> } | |
| const env = parsed.env ?? {} | |
| if (env[OPENAI_OAUTH_PROVIDER_ENV_KEY] === '1') { | |
| return false | |
| } | |
| 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[] { | |
| // The standalone dist/cli binary (April 18, pre-fork) is known to be | |
| // broken for --print / SDK mode — it parses --sdk-url but never | |
| // establishes the WebSocket connection and produces no stream-json | |
| // output. Bypass it unconditionally and delegate to the sidecar or | |
| // source-based launcher instead. | |
| // | |
| // When running inside the compiled sidecar, import.meta.dir resolves to | |
| // a virtual bunfs path — use CLAUDE_APP_ROOT (set by the sidecar | |
| // launcher) to find the repo root for source-based fallback. | |
| const launcher = resolveClaudeCliLauncher({ | |
| cliPath: process.env.CLAUDE_CLI_PATH, | |
| execPath: process.execPath, | |
| }) | |
| if (launcher) { | |
| return buildClaudeCliArgs(launcher, baseArgs, process.env.CLAUDE_APP_ROOT) | |
| } | |
| // No launcher detected — try running from source via bun. | |
| const repoRoot = process.env.CLAUDE_APP_ROOT | |
| ? path.resolve(process.env.CLAUDE_APP_ROOT, '../../../..') | |
| : path.resolve(import.meta.dir, '../../..') | |
| const sourceEntry = path.resolve(repoRoot, 'src/entrypoints/cli.tsx') | |
| if (fs.existsSync(sourceEntry)) { | |
| return [process.execPath, sourceEntry, ...baseArgs] | |
| } | |
| if (process.platform === 'win32') { | |
| return [ | |
| process.execPath, | |
| '--preload', | |
| path.resolve(import.meta.dir, '../../../preload.ts'), | |
| path.resolve(import.meta.dir, '../../entrypoints/cli.tsx'), | |
| ...baseArgs, | |
| ] | |
| } | |
| // Try claude-haha from PATH (installed via npm/pip) | |
| return ['claude-haha', ...baseArgs] | |
| } | |
| 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<string, unknown> | |
| } | 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<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, | |
| } | |
| }) | |
| } | |
| private buildUserContent( | |
| content: string, | |
| sessionId: string, | |
| attachments?: AttachmentRef[], | |
| ): Array<Record<string, unknown>> { | |
| 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() | |