cc-haha/src/server/services/conversationService.ts
程序员阿江(Relakkes) b72bca94e3 fix: wait for sdk socket before context inspection
Desktop context inspection could send get_context_usage while a prewarmed CLI process existed but before the SDK socket was connected. That first control request sat in the pending outbound queue and could time out, leaving the composer spinner or a misleading empty-state context display. The server now waits for the SDK socket before sending control requests, and the UI renders the actual initial context snapshot instead of masking empty active-session data.

Constraint: CLI /context and desktop get_context_usage must stay on the shared collectContextData/analyzeContextUsage path

Rejected: Hide zero-token active snapshots in the component | that masks backend timing bugs and prevents showing real initial system/tool context

Confidence: high

Scope-risk: narrow

Directive: Do not queue control requests before the SDK socket is connected; let user messages keep the pending outbound behavior

Tested: bun test src/server/__tests__/conversations.test.ts --test-name-pattern "not queue control|context-only|prewarmed empty session|structured session inspection"

Tested: cd desktop && bun run test -- pages.test.tsx --test-name-pattern "EmptySession shows draft context|first-paint spinner|empty live session|live context usage|runtime model"

Tested: bun run check:server

Tested: cd desktop && bun run lint

Tested: real dev API prewarmed empty session returned 31,190 / 262,144 (12%) on first context-only inspection and 16ms on repeat

Tested: browser at http://127.0.0.1:5174/ showed 上下文用量 12% for an empty active session
2026-05-05 20:10:04 +08:00

1357 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<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,
): 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<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
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: workDirCLI 子进程里 preload.ts 还是会 chdir('/'),结果把
// STATE.cwd / "Primary working directory" 打回根目录IM 会话里 AI 感知的
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDirpreload.ts
// chdir 后落到正确目录。
//
const childEnv = await this.buildChildEnv(workDir, sdkUrl, options)
let proc: ReturnType<typeof Bun.spawn>
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<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,
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<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,
},
})
}
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 || ''
}
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)
}
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<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 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_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<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
}
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_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<string, string> }
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<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()