cc-haha/src/server/services/conversationService.ts
程序员阿江(Relakkes) e7a59592de fix(streaming): honor request timeout while waiting for the first token (#826)
The configured request timeout (API_TIMEOUT_MS) only feeds the SDK client
timeout, which on a streaming request is cleared the moment response headers
arrive. Slow local/3P models return 200 headers fast but spend minutes
prefilling before the first SSE token, so that window was governed by the idle
watchdog (CLAUDE_STREAM_IDLE_TIMEOUT_MS, 240s) -- a separate env users cannot
configure. The configured timeout therefore never applied to slow prefill, so
raising it had no effect (#826, follow-up to #449).

Split the stream watchdog into phases: use a first-token budget (tied to the
user's request timeout via CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS) until the first
chunk arrives, then switch to the shorter mid-stream idle budget. The overall
max-duration cap (#766) still backstops trickling bodies; CLI defaults fall back
to prior behavior.
2026-06-15 23:34:32 +08:00

1874 lines
60 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 {
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'
import { readTraceCaptureSettings } from './traceCaptureService.js'
import { logError } from '../../utils/log.js'
import {
createImageMetadataText,
maybeResizeAndDownsampleImageBuffer,
} from '../../utils/imageResizer.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'
export const DESKTOP_CLI_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 6_000
/**
* Severity for a CLI subprocess exit, by exit code.
*
* Reaching handleProcessExit already means the process left outside the clean
* stop path, but the exit code still tells crash from teardown:
* - 0 clean exit
* - null terminated by a signal with no numeric code
* - 143 (SIGTERM), 137 (SIGKILL): killed — shutdown / user stop / OS reclaim
* None of these are a crash the user needs flagged in red. Any other non-zero
* code is a genuine "it died mid-chat" failure and stays an error.
*/
export function cliExitSeverity(code: number | null): 'info' | 'error' {
if (code === 0 || code === null || code === 143 || code === 137) return 'info'
return 'error'
}
export function buildConversationCliSpawnOptions(
cwd: string,
env: NodeJS.ProcessEnv,
) {
return {
cwd,
env,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
windowsHide: true,
} as const
}
type AttachmentRef = {
type: 'file' | 'image'
name?: string
path?: string
data?: string
mimeType?: string
isDirectory?: boolean
}
type UserContentBlock = Record<string, unknown>
type MaterializedAttachments = {
pathPrefix: string
imageBlocks: UserContentBlock[]
imageMetadataTexts: 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
usesOfficialOAuth: boolean
officialOAuthToken: string | null
pendingPermissionRequests: Map<
string,
{
toolName: string
toolUseId?: string
description?: string
input: Record<string, unknown>
permissionSuggestions?: unknown[]
}
>
}
export type PendingPermissionRequest = {
requestId: string
toolName: string
toolUseId?: string
input: Record<string, unknown>
description?: string
}
type SessionStartOptions = {
permissionMode?: string
model?: string
effort?: string
thinking?: 'enabled' | 'adaptive' | 'disabled'
providerId?: string | null
resumeInterruptedTurn?: boolean
}
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: workDirCLI 子进程里 preload.ts 还是会 chdir('/'),结果把
// STATE.cwd / "Primary working directory" 打回根目录IM 会话里 AI 感知的
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDirpreload.ts
// chdir 后落到正确目录。
//
const childEnv = await this.buildChildEnv(launchWorkDir, sdkUrl, options)
const usesOfficialOAuth = this.shouldMarkManagedOAuth(options?.providerId)
let proc: ReturnType<typeof Bun.spawn>
try {
proc = Bun.spawn(args, buildConversationCliSpawnOptions(launchWorkDir, childEnv))
} 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,
usesOfficialOAuth,
officialOAuthToken: childEnv.CLAUDE_CODE_OAUTH_TOKEN ?? 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,
permissionMode: options?.permissionMode || launchInfo?.permissionMode,
})
}
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
}
async sendMessage(
sessionId: string,
content: string,
attachments?: AttachmentRef[],
): Promise<boolean> {
const session = this.sessions.get(sessionId)
if (session) {
await this.refreshOfficialOAuthTokenBeforeTurn(sessionId, session)
}
const userContent = await this.buildUserContent(content, sessionId, attachments)
return this.sendSdkMessage(sessionId, {
type: 'user',
message: {
role: 'user',
content: userContent,
},
parent_tool_use_id: null,
session_id: '',
})
}
respondToPermission(
sessionId: string,
requestId: string,
allowed: boolean,
rule?: string,
updatedInput?: Record<string, unknown>,
denyMessage?: string,
permissionUpdates?: 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 ?? {},
...(Array.isArray(permissionUpdates) && permissionUpdates.length > 0
? { updatedPermissions: permissionUpdates }
: rule === 'always' && pendingRequest
? {
updatedPermissions: [
...normalizeSessionPermissionUpdates(
pendingRequest.permissionSuggestions,
pendingRequest.toolName,
),
],
}
: {}),
}
: { behavior: 'deny', message: denyMessage || 'User denied via UI' },
},
})
}
setPermissionMode(sessionId: string, mode: string): boolean {
const sent = this.sendSdkMessage(sessionId, {
type: 'control_request',
request_id: crypto.randomUUID(),
request: {
subtype: 'set_permission_mode',
mode,
},
})
if (sent) {
const session = this.sessions.get(sessionId)
if (session) session.permissionMode = mode
}
return sent
}
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'
}
getPendingPermissionRequests(sessionId: string): PendingPermissionRequest[] {
const session = this.sessions.get(sessionId)
if (!session) return []
return Array.from(session.pendingPermissionRequests.entries()).map(([requestId, request]) => ({
requestId,
toolName: request.toolName,
...(request.toolUseId ? { toolUseId: request.toolUseId } : {}),
input: request.input,
...(request.description ? { description: request.description } : {}),
}))
}
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',
toolUseId:
typeof msg.request.tool_use_id === 'string' && msg.request.tool_use_id.trim()
? msg.request.tool_use_id
: undefined,
input:
msg.request.input && typeof msg.request.input === 'object'
? (msg.request.input as Record<string, unknown>)
: {},
description:
typeof msg.request.description === 'string' && msg.request.description.trim()
? msg.request.description
: undefined,
permissionSuggestions: Array.isArray(msg.request.permission_suggestions)
? msg.request.permission_suggestions
: undefined,
})
}
if (
(msg?.type === 'control_cancel_request' || msg?.type === 'control_response') &&
typeof msg.request_id === 'string'
) {
session.pendingPermissionRequests.delete(msg.request_id)
}
if (
msg?.type === 'control_response' &&
typeof msg.response?.request_id === 'string'
) {
session.pendingPermissionRequests.delete(msg.response.request_id)
}
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) return
this.sessions.delete(sessionId)
this.killProcess(sessionId, session)
}
async stopSessionAndWait(
sessionId: string,
timeoutMs = DESKTOP_CLI_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
): Promise<void> {
const session = this.sessions.get(sessionId)
if (!session) return
this.sessions.delete(sessionId)
await this.stopProcessAndWait(sessionId, session, timeoutMs)
}
stopAllSessions(): void {
for (const sessionId of this.getActiveSessions()) {
this.stopSession(sessionId)
}
}
async stopAllSessionsAndWait(
timeoutMs = DESKTOP_CLI_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
): Promise<void> {
const activeSessions = Array.from(this.sessions.entries())
if (activeSessions.length === 0) return
this.sessions.clear()
await Promise.all(
activeSessions.map(([sessionId, session]) =>
this.stopProcessAndWait(sessionId, session, timeoutMs),
),
)
}
private async stopProcessAndWait(
sessionId: string,
session: SessionProcess,
timeoutMs: number,
): Promise<void> {
this.killProcess(sessionId, session, 'SIGTERM')
const exited = await Promise.race([
session.proc.exited.then(() => true, () => true),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), timeoutMs)),
])
if (!exited) {
this.killProcess(sessionId, session, 'SIGKILL')
await Promise.race([
session.proc.exited.catch(() => undefined),
new Promise<void>((resolve) => setTimeout(resolve, 500)),
])
}
await this.waitForProcessOutputDrain(session, timeoutMs)
}
private killProcess(
sessionId: string,
session: SessionProcess,
signal?: NodeJS.Signals,
): void {
try {
session.proc.kill(signal)
} catch (error) {
console.warn(
`[ConversationService] Failed to kill CLI subprocess for ${sessionId}: ${
error instanceof Error ? error.message : String(error)
}`,
)
}
}
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: cliExitSeverity(code),
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_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 (options?.resumeInterruptedTurn === false) {
delete cleanEnv.CLAUDE_CODE_RESUME_INTERRUPTED_TURN
}
delete cleanEnv.CC_HAHA_TRACE_PROVIDER_ID
delete cleanEnv.CC_HAHA_TRACE_PROVIDER_NAME
delete cleanEnv.CC_HAHA_TRACE_PROVIDER_FORMAT
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 explicitProvider =
typeof options?.providerId === 'string'
? await this.providerService.getProvider(options.providerId)
: null
const explicitProviderEnv = explicitProvider
? await this.providerService.getProviderRuntimeEnv(explicitProvider.id)
: null
const networkEnv = buildNetworkEnvironment(await loadNetworkSettings())
const traceCaptureEnabled = (await readTraceCaptureSettings()).enabled
if (explicitProviderEnv && options?.model?.trim()) {
explicitProviderEnv.ANTHROPIC_MODEL = options.model.trim()
}
const attributionHeaderEnv = attributionHeaderEnvForModel(
options?.model?.trim() ||
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',
// Desktop must fail stuck provider streams instead of leaving the UI running forever.
CLAUDE_ENABLE_STREAM_WATCHDOG: cleanEnv.CLAUDE_ENABLE_STREAM_WATCHDOG || '1',
// Third-party providers can stay silent for minutes mid-stream (thinking
// phases emit no SSE bytes, and many gateways never send pings), so the
// CLI's 90s idle default kills healthy streams (#766). 240s still frees
// a truly dead connection without shooting slow ones.
CLAUDE_STREAM_IDLE_TIMEOUT_MS: cleanEnv.CLAUDE_STREAM_IDLE_TIMEOUT_MS || '240000',
// Overall wall-clock cap for one streaming response, NOT reset by chunks.
// The 240s idle timer above is reset by every SSE event, so an upstream
// that trickles content deltas (e.g. a huge tool_use input_json_delta)
// just under 240s apart keeps it alive forever and the request hangs with
// no completion (#766: "卡住" with slowly growing tokens). This independent
// cap frees such a stream after a fixed duration regardless of trickle.
CLAUDE_STREAM_MAX_DURATION_MS: cleanEnv.CLAUDE_STREAM_MAX_DURATION_MS || '600000',
// Time-to-first-token budget: how long to wait for the FIRST streamed
// chunk after response headers arrive. The idle timer above is the wrong
// knob for slow prefill — it kills healthy local/3P models that take
// minutes to emit their first token (#826). Tie this to the user's
// request-timeout setting (API_TIMEOUT_MS, from networkEnv) so raising
// "请求超时" actually extends how long we wait for the first token. The
// CLI switches to the shorter idle budget once tokens start flowing.
CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS:
cleanEnv.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS || networkEnv.API_TIMEOUT_MS,
// When a stream does get aborted, retry as streaming instead of falling
// back to non-streaming: a non-streaming request must wait for the FULL
// generation before the first response byte, so slow providers can never
// finish inside API_TIMEOUT_MS — the fallback loops 5-minute aborts
// forever while the UI shows "running" (#766). It can also double-run
// tools (upstream inc-4258).
CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: cleanEnv.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK || '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' }
: {}),
...(sdkUrl && traceCaptureEnabled
? { CC_HAHA_TRACE_API_CALLS: '1' }
: {}),
...(sdkUrl && traceCaptureEnabled && explicitProvider
? {
CC_HAHA_TRACE_PROVIDER_ID: explicitProvider.id,
CC_HAHA_TRACE_PROVIDER_NAME: explicitProvider.name,
CC_HAHA_TRACE_PROVIDER_FORMAT: explicitProvider.apiFormat ?? 'anthropic',
}
: {}),
...(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 ?? {}),
...networkEnv,
...(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 async refreshOfficialOAuthTokenBeforeTurn(
sessionId: string,
session: SessionProcess,
): Promise<void> {
if (!session.usesOfficialOAuth) return
let token: string | null = null
try {
const { hahaOAuthService } = await import('./hahaOAuthService.js')
token = await hahaOAuthService.ensureFreshAccessToken()
} catch (err) {
console.error(
'[conversationService] refresh official OAuth token before turn failed:',
err instanceof Error ? err.message : err,
)
return
}
if (!token || token === session.officialOAuthToken) return
session.officialOAuthToken = token
this.sendSdkMessage(sessionId, {
type: 'update_environment_variables',
variables: { CLAUDE_CODE_OAUTH_TOKEN: token },
})
}
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_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
}
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[] {
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 async buildUserContent(
content: string,
sessionId: string,
attachments?: AttachmentRef[],
): Promise<UserContentBlock[]> {
const materialized = await this.materializeAttachments(sessionId, attachments)
const trimmed = content.trim()
const text = materialized.pathPrefix
? `${materialized.pathPrefix}${trimmed || 'Please analyze the attached files.'}`.trim()
: trimmed
const blocks: UserContentBlock[] = text
? [{ type: 'text', text }]
: materialized.imageBlocks.length > 0
? [{ type: 'text', text: 'Please analyze the attached image.' }]
: []
blocks.push(...materialized.imageBlocks)
for (const metadataText of materialized.imageMetadataTexts) {
blocks.push({ type: 'text', text: metadataText })
}
return blocks.length > 0 ? blocks : [{ type: 'text', text: '' }]
}
private async materializeAttachments(
sessionId: string,
attachments?: AttachmentRef[],
): Promise<MaterializedAttachments> {
const empty = (): MaterializedAttachments => ({
pathPrefix: '',
imageBlocks: [],
imageMetadataTexts: [],
})
if (!attachments || attachments.length === 0) {
return empty()
}
const uploadDir = path.join(
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'),
'uploads',
sessionId,
)
const savedPaths: string[] = []
const imageBlocks: UserContentBlock[] = []
const imageMetadataTexts: string[] = []
for (const attachment of attachments) {
if (this.shouldInlineImageAttachment(attachment)) {
const image = await this.materializeImageAttachment(attachment, uploadDir)
if (image) {
imageBlocks.push(image.block)
if (image.metadataText) imageMetadataTexts.push(image.metadataText)
continue
}
}
if (attachment.path) {
savedPaths.push(attachment.path)
continue
}
if (!attachment.data) continue
const parsed = this.parseAttachmentData(attachment.data)
if (!parsed) continue
const ext = this.getAttachmentExtension({
...attachment,
mimeType: attachment.mimeType ?? parsed.mimeType,
})
const fileName = this.sanitizeAttachmentName(attachment.name, attachment.type, ext)
const outPath = this.writeUploadAttachment(uploadDir, fileName, parsed.payload)
savedPaths.push(outPath)
}
return {
pathPrefix: savedPaths.length > 0
? savedPaths.map((filePath) => `@"${filePath}"`).join(' ') + ' '
: '',
imageBlocks,
imageMetadataTexts,
}
}
private parseAttachmentData(data: string): { payload: Buffer; mimeType?: string } | null {
const match = data.match(/^data:([^;,]+)?;base64,(.*)$/)
const encoded = match ? match[2] : data
try {
return {
payload: Buffer.from(encoded ?? '', 'base64'),
mimeType: match?.[1],
}
} catch {
return null
}
}
private async materializeImageAttachment(
attachment: AttachmentRef,
uploadDir: string,
): Promise<{ block: UserContentBlock; metadataText?: string } | null> {
const source = this.readImageAttachmentPayload(attachment)
if (!source) {
return null
}
try {
const resized = await maybeResizeAndDownsampleImageBuffer(
source.payload,
source.payload.length,
source.ext,
)
const normalizedExt = this.normalizeImageExtension(resized.mediaType)
const storedName = this.replaceFileExtension(
this.sanitizeAttachmentName(attachment.name, attachment.type, normalizedExt),
normalizedExt,
)
const sourcePath = source.sourcePath ?? this.writeUploadAttachment(
uploadDir,
storedName,
resized.buffer,
)
const metadataText = resized.dimensions
? createImageMetadataText(resized.dimensions, sourcePath)
: sourcePath
? `[Image source: ${sourcePath}]`
: undefined
return {
block: {
type: 'image',
source: {
type: 'base64',
media_type: `image/${normalizedExt}`,
data: resized.buffer.toString('base64'),
},
},
metadataText: metadataText ?? undefined,
}
} catch (error) {
logError(error)
console.warn(
`[ConversationService] Failed to inline image attachment ${attachment.name ?? '<unnamed>'}; falling back to file path`,
)
return null
}
}
private readImageAttachmentPayload(
attachment: AttachmentRef,
): { payload: Buffer; ext: string; sourcePath?: string } | null {
if (attachment.data) {
const parsed = this.parseAttachmentData(attachment.data)
if (!parsed) return null
return {
payload: parsed.payload,
ext: this.getAttachmentExtension({
...attachment,
mimeType: attachment.mimeType ?? parsed.mimeType,
}),
}
}
if (!attachment.path || attachment.isDirectory) {
return null
}
try {
return {
payload: fs.readFileSync(attachment.path),
ext: this.getAttachmentExtension(attachment),
sourcePath: attachment.path,
}
} catch (error) {
logError(error)
return null
}
}
private shouldInlineImageAttachment(attachment: AttachmentRef): boolean {
if (attachment.isDirectory) return false
if (attachment.type === 'image') return true
if (attachment.mimeType?.startsWith('image/')) return true
const candidate = attachment.path ?? attachment.name ?? ''
return /\.(png|jpe?g|gif|webp)$/i.test(candidate)
}
private writeUploadAttachment(uploadDir: string, fileName: string, payload: Buffer): string {
fs.mkdirSync(uploadDir, { recursive: true })
const outPath = path.join(uploadDir, `${crypto.randomUUID()}-${fileName}`)
fs.writeFileSync(outPath, payload)
return outPath
}
private normalizeImageExtension(ext: string): string {
const clean = ext.split('/').pop()?.split('+')[0]?.toLowerCase() || 'png'
return clean === 'jpg' ? 'jpeg' : clean
}
private replaceFileExtension(fileName: string, ext: string): string {
const cleanExt = this.normalizeImageExtension(ext)
const base = fileName.replace(/\.[a-z0-9]+$/i, '')
return `${base}.${cleanExt}`
}
private getAttachmentExtension(attachment: AttachmentRef): string {
const byName = attachment.name?.match(/\.([a-z0-9]+)$/i)?.[1]
if (byName) return byName
const byPath = attachment.path?.match(/\.([a-z0-9]+)$/i)?.[1]
if (byPath) return byPath
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()