mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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
1357 lines
42 KiB
TypeScript
1357 lines
42 KiB
TypeScript
/**
|
||
* 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: workDir,CLI 子进程里 preload.ts 还是会 chdir('/'),结果把
|
||
// STATE.cwd / "Primary working directory" 打回根目录,IM 会话里 AI 感知的
|
||
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.ts
|
||
// chdir 后落到正确目录。
|
||
//
|
||
const childEnv = await this.buildChildEnv(workDir, sdkUrl, options)
|
||
|
||
let proc: ReturnType<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()
|