mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 14:41:24 +08:00
* feat: auto-discover and persist Git Bash path on Windows - Add autoDiscoverGitBash function to find and cache Git Bash path when needed - Modify System_CheckGitBash IPC handler to auto-discover and persist path - Update Claude Code service with fallback auto-discovery mechanism - Git Bash path is now cached after first discovery, improving UX for Windows users * udpate * fix: remove redundant validation of auto-discovered Git Bash path The autoDiscoverGitBash function already returns a validated path, so calling validateGitBashPath again is unnecessary. Co-Authored-By: Claude <noreply@anthropic.com> * udpate * test: add unit tests for autoDiscoverGitBash function Add comprehensive test coverage for autoDiscoverGitBash including: - Discovery with no existing config path - Validation of existing config paths - Handling of invalid existing paths - Config persistence verification - Real-world scenarios (standard Git, portable Git, user-configured paths) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unnecessary async keyword from System_CheckGitBash handler The handler doesn't use await since autoDiscoverGitBash is synchronous. Removes async for consistency with other IPC handlers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: rename misleading test to match actual behavior Renamed "should not call configManager.set multiple times on single discovery" to "should persist on each discovery when config remains undefined" to accurately describe that each call to autoDiscoverGitBash persists when the config mock returns undefined. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: use generic type parameter instead of type assertion Replace `as string | undefined` with `get<string | undefined>()` for better type safety when retrieving GitBashPath from config. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: simplify Git Bash path resolution in Claude Code service Remove redundant validateGitBashPath call since autoDiscoverGitBash already handles validation of configured paths before attempting discovery. Also remove unused ConfigKeys and configManager imports. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: attempt auto-discovery when configured Git Bash path is invalid Previously, if a user had an invalid configured path (e.g., Git was moved or uninstalled), autoDiscoverGitBash would return null without attempting to find a valid installation. Now it logs a warning and attempts auto-discovery, providing a better user experience by automatically fixing invalid configurations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: ensure CLAUDE_CODE_GIT_BASH_PATH env var takes precedence over config Previously, if a valid config path existed, the environment variable CLAUDE_CODE_GIT_BASH_PATH was never checked. Now the precedence order is: 1. CLAUDE_CODE_GIT_BASH_PATH env var (highest - runtime override) 2. Configured path from settings 3. Auto-discovery via findGitBash This allows users to temporarily override the configured path without modifying their persistent settings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: improve code quality and test robustness - Remove duplicate logging in Claude Code service (autoDiscoverGitBash logs internally) - Simplify Git Bash path initialization with ternary expression - Add afterEach cleanup to restore original env vars in tests - Extract mockExistingPaths helper to reduce test code duplication 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: track Git Bash path source to distinguish manual vs auto-discovered - Add GitBashPathSource type and GitBashPathInfo interface to shared constants - Add GitBashPathSource config key to persist path origin ('manual' | 'auto') - Update autoDiscoverGitBash to mark discovered paths as 'auto' - Update setGitBashPath IPC to mark user-set paths as 'manual' - Add getGitBashPathInfo API to retrieve path with source info - Update AgentModal UI to show different text based on source: - Manual: "Using custom path" with clear button - Auto: "Auto-discovered" without clear button 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: simplify Git Bash config UI as form field - Replace large Alert components with compact form field - Use static isWin constant instead of async platform detection - Show Git Bash field only on Windows with auto-fill support - Disable save button when Git Bash path is missing on Windows - Add "Auto-discovered" hint for auto-detected paths - Remove hasGitBash state, simplify checkGitBash logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ui: add explicit select button for Git Bash path Replace click-on-input interaction with a dedicated "Select" button for clearer UX 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: simplify Git Bash UI by removing clear button - Remove handleClearGitBash function (no longer needed) - Remove clear button from UI (auto-discover fills value, user can re-select) - Remove auto-discovered hint (SourceHint) - Remove unused SourceHint styled component 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: add reset button to restore auto-discovered Git Bash path - Add handleResetGitBash to clear manual setting and re-run auto-discovery - Show "Reset" button only when source is 'manual' - Show "Auto-discovered" hint when path was found automatically - User can re-select if auto-discovered path is not suitable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: re-run auto-discovery when resetting Git Bash path When setGitBashPath(null) is called (reset), now automatically re-runs autoDiscoverGitBash() to restore the auto-discovered path. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat(i18n): add Git Bash config translations Add translations for: - autoDiscoveredHint: hint text for auto-discovered paths - placeholder: input placeholder for bash.exe selection - tooltip: help tooltip text - error.required: validation error message Supported languages: en-US, zh-CN, zh-TW 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * update i18n * fix: auto-discover Git Bash when getting path info When getGitBashPathInfo() is called and no path is configured, automatically trigger autoDiscoverGitBash() first. This handles the upgrade scenario from old versions that don't have Git Bash path configured. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
567 lines
18 KiB
TypeScript
567 lines
18 KiB
TypeScript
// src/main/services/agents/services/claudecode/index.ts
|
|
import { EventEmitter } from 'node:events'
|
|
import { createRequire } from 'node:module'
|
|
import path from 'node:path'
|
|
|
|
import type {
|
|
CanUseTool,
|
|
HookCallback,
|
|
McpHttpServerConfig,
|
|
Options,
|
|
PreToolUseHookInput,
|
|
SDKMessage
|
|
} from '@anthropic-ai/claude-agent-sdk'
|
|
import { query } from '@anthropic-ai/claude-agent-sdk'
|
|
import { loggerService } from '@logger'
|
|
import { config as apiConfigService } from '@main/apiServer/config'
|
|
import { validateModelId } from '@main/apiServer/utils'
|
|
import { isWin } from '@main/constant'
|
|
import { autoDiscoverGitBash } from '@main/utils/process'
|
|
import getLoginShellEnvironment from '@main/utils/shell-env'
|
|
import { app } from 'electron'
|
|
|
|
import type { GetAgentSessionResponse } from '../..'
|
|
import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
|
|
import { sessionService } from '../SessionService'
|
|
import { buildNamespacedToolCallId } from './claude-stream-state'
|
|
import { promptForToolApproval } from './tool-permissions'
|
|
import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform'
|
|
|
|
const require_ = createRequire(import.meta.url)
|
|
const logger = loggerService.withContext('ClaudeCodeService')
|
|
const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep'])
|
|
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
|
|
const NO_RESUME_COMMANDS = ['/clear']
|
|
|
|
type UserInputMessage = {
|
|
type: 'user'
|
|
parent_tool_use_id: string | null
|
|
session_id: string
|
|
message: {
|
|
role: 'user'
|
|
content: string
|
|
}
|
|
}
|
|
|
|
class ClaudeCodeStream extends EventEmitter implements AgentStream {
|
|
declare emit: (event: 'data', data: AgentStreamEvent) => boolean
|
|
declare on: (event: 'data', listener: (data: AgentStreamEvent) => void) => this
|
|
declare once: (event: 'data', listener: (data: AgentStreamEvent) => void) => this
|
|
}
|
|
|
|
class ClaudeCodeService implements AgentServiceInterface {
|
|
private claudeExecutablePath: string
|
|
|
|
constructor() {
|
|
// Resolve Claude Code CLI robustly (works in dev and in asar)
|
|
this.claudeExecutablePath = require_.resolve('@anthropic-ai/claude-agent-sdk/cli.js')
|
|
if (app.isPackaged) {
|
|
this.claudeExecutablePath = this.claudeExecutablePath.replace(/\.asar([\\/])/, '.asar.unpacked$1')
|
|
}
|
|
}
|
|
|
|
async invoke(
|
|
prompt: string,
|
|
session: GetAgentSessionResponse,
|
|
abortController: AbortController,
|
|
lastAgentSessionId?: string
|
|
): Promise<AgentStream> {
|
|
const aiStream = new ClaudeCodeStream()
|
|
|
|
// Validate session accessible paths and make sure it exists as a directory
|
|
const cwd = session.accessible_paths[0]
|
|
if (!cwd) {
|
|
aiStream.emit('data', {
|
|
type: 'error',
|
|
error: new Error('No accessible paths defined for the agent session')
|
|
})
|
|
return aiStream
|
|
}
|
|
|
|
// Validate model info
|
|
const modelInfo = await validateModelId(session.model)
|
|
if (!modelInfo.valid) {
|
|
aiStream.emit('data', {
|
|
type: 'error',
|
|
error: new Error(`Invalid model ID '${session.model}': ${JSON.stringify(modelInfo.error)}`)
|
|
})
|
|
return aiStream
|
|
}
|
|
if (
|
|
(modelInfo.provider?.type !== 'anthropic' &&
|
|
(modelInfo.provider?.anthropicApiHost === undefined || modelInfo.provider.anthropicApiHost.trim() === '')) ||
|
|
modelInfo.provider.apiKey === ''
|
|
) {
|
|
logger.error('Anthropic provider configuration is missing', {
|
|
modelInfo
|
|
})
|
|
|
|
aiStream.emit('data', {
|
|
type: 'error',
|
|
error: new Error(`Invalid provider type '${modelInfo.provider?.type}'. Expected 'anthropic' provider type.`)
|
|
})
|
|
return aiStream
|
|
}
|
|
|
|
const apiConfig = await apiConfigService.get()
|
|
const loginShellEnv = await getLoginShellEnvironment()
|
|
const loginShellEnvWithoutProxies = Object.fromEntries(
|
|
Object.entries(loginShellEnv).filter(([key]) => !key.toLowerCase().endsWith('_proxy'))
|
|
) as Record<string, string>
|
|
|
|
// Auto-discover Git Bash path on Windows (already logs internally)
|
|
const customGitBashPath = isWin ? autoDiscoverGitBash() : null
|
|
|
|
const env = {
|
|
...loginShellEnvWithoutProxies,
|
|
// TODO: fix the proxy api server
|
|
// ANTHROPIC_API_KEY: apiConfig.apiKey,
|
|
// ANTHROPIC_AUTH_TOKEN: apiConfig.apiKey,
|
|
// ANTHROPIC_BASE_URL: `http://${apiConfig.host}:${apiConfig.port}/${modelInfo.provider.id}`,
|
|
ANTHROPIC_API_KEY: modelInfo.provider.apiKey,
|
|
ANTHROPIC_AUTH_TOKEN: modelInfo.provider.apiKey,
|
|
ANTHROPIC_BASE_URL: modelInfo.provider.anthropicApiHost?.trim() || modelInfo.provider.apiHost,
|
|
ANTHROPIC_MODEL: modelInfo.modelId,
|
|
ANTHROPIC_DEFAULT_OPUS_MODEL: modelInfo.modelId,
|
|
ANTHROPIC_DEFAULT_SONNET_MODEL: modelInfo.modelId,
|
|
// TODO: support set small model in UI
|
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: modelInfo.modelId,
|
|
ELECTRON_RUN_AS_NODE: '1',
|
|
ELECTRON_NO_ATTACH_CONSOLE: '1',
|
|
// Set CLAUDE_CONFIG_DIR to app's userData directory to avoid path encoding issues
|
|
// on Windows when the username contains non-ASCII characters (e.g., Chinese characters)
|
|
// This prevents the SDK from using the user's home directory which may have encoding problems
|
|
CLAUDE_CONFIG_DIR: path.join(app.getPath('userData'), '.claude'),
|
|
...(customGitBashPath ? { CLAUDE_CODE_GIT_BASH_PATH: customGitBashPath } : {})
|
|
}
|
|
|
|
const errorChunks: string[] = []
|
|
|
|
const sessionAllowedTools = new Set<string>(session.allowed_tools ?? [])
|
|
const autoAllowTools = new Set<string>([...DEFAULT_AUTO_ALLOW_TOOLS, ...sessionAllowedTools])
|
|
const normalizeToolName = (name: string) => (name.startsWith('builtin_') ? name.slice('builtin_'.length) : name)
|
|
|
|
const canUseTool: CanUseTool = async (toolName, input, options) => {
|
|
logger.info('Handling tool permission check', {
|
|
toolName,
|
|
suggestionCount: options.suggestions?.length ?? 0
|
|
})
|
|
|
|
if (shouldAutoApproveTools) {
|
|
logger.debug('Auto-approving tool due to CHERRY_AUTO_ALLOW_TOOLS flag', { toolName })
|
|
return { behavior: 'allow', updatedInput: input }
|
|
}
|
|
|
|
if (options.signal.aborted) {
|
|
logger.debug('Permission request signal already aborted; denying tool', { toolName })
|
|
return {
|
|
behavior: 'deny',
|
|
message: 'Tool request was cancelled before prompting the user'
|
|
}
|
|
}
|
|
|
|
const normalizedToolName = normalizeToolName(toolName)
|
|
if (autoAllowTools.has(toolName) || autoAllowTools.has(normalizedToolName)) {
|
|
logger.debug('Auto-allowing tool from allowed list', {
|
|
toolName,
|
|
normalizedToolName
|
|
})
|
|
return { behavior: 'allow', updatedInput: input }
|
|
}
|
|
|
|
return promptForToolApproval(toolName, input, {
|
|
...options,
|
|
toolCallId: buildNamespacedToolCallId(session.id, options.toolUseID)
|
|
})
|
|
}
|
|
|
|
const preToolUseHook: HookCallback = async (input, toolUseID, options) => {
|
|
// Type guard to ensure we're handling PreToolUse event
|
|
if (input.hook_event_name !== 'PreToolUse') {
|
|
return {}
|
|
}
|
|
|
|
const hookInput = input as PreToolUseHookInput
|
|
const toolName = hookInput.tool_name
|
|
|
|
logger.debug('PreToolUse hook triggered', {
|
|
session_id: hookInput.session_id,
|
|
tool_name: hookInput.tool_name,
|
|
tool_use_id: toolUseID,
|
|
tool_input: hookInput.tool_input,
|
|
cwd: hookInput.cwd,
|
|
permission_mode: hookInput.permission_mode,
|
|
autoAllowTools: autoAllowTools
|
|
})
|
|
|
|
if (options?.signal?.aborted) {
|
|
logger.debug('PreToolUse hook signal already aborted; skipping tool use', {
|
|
tool_name: hookInput.tool_name
|
|
})
|
|
return {}
|
|
}
|
|
|
|
// handle auto approved tools since it never triggers canUseTool
|
|
const normalizedToolName = normalizeToolName(toolName)
|
|
if (toolUseID) {
|
|
const bypassAll = input.permission_mode === 'bypassPermissions'
|
|
const autoAllowed = autoAllowTools.has(toolName) || autoAllowTools.has(normalizedToolName)
|
|
if (bypassAll || autoAllowed) {
|
|
const namespacedToolCallId = buildNamespacedToolCallId(session.id, toolUseID)
|
|
logger.debug('handling auto approved tools', {
|
|
toolName,
|
|
normalizedToolName,
|
|
namespacedToolCallId,
|
|
permission_mode: input.permission_mode,
|
|
autoAllowTools
|
|
})
|
|
const isRecord = (v: unknown): v is Record<string, unknown> => {
|
|
return !!v && typeof v === 'object' && !Array.isArray(v)
|
|
}
|
|
const toolInput = isRecord(input.tool_input) ? input.tool_input : {}
|
|
|
|
await promptForToolApproval(toolName, toolInput, {
|
|
...options,
|
|
toolCallId: namespacedToolCallId,
|
|
autoApprove: true
|
|
})
|
|
}
|
|
}
|
|
|
|
// Return to proceed without modification
|
|
return {}
|
|
}
|
|
|
|
// Build SDK options from parameters
|
|
const options: Options = {
|
|
abortController,
|
|
cwd,
|
|
env,
|
|
// model: modelInfo.modelId,
|
|
pathToClaudeCodeExecutable: this.claudeExecutablePath,
|
|
stderr: (chunk: string) => {
|
|
logger.warn('claude stderr', { chunk })
|
|
errorChunks.push(chunk)
|
|
},
|
|
systemPrompt: session.instructions
|
|
? {
|
|
type: 'preset',
|
|
preset: 'claude_code',
|
|
append: session.instructions
|
|
}
|
|
: { type: 'preset', preset: 'claude_code' },
|
|
settingSources: ['project'],
|
|
includePartialMessages: true,
|
|
permissionMode: session.configuration?.permission_mode,
|
|
maxTurns: session.configuration?.max_turns,
|
|
allowedTools: session.allowed_tools,
|
|
canUseTool,
|
|
hooks: {
|
|
PreToolUse: [
|
|
{
|
|
hooks: [preToolUseHook]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
if (session.accessible_paths.length > 1) {
|
|
options.additionalDirectories = session.accessible_paths.slice(1)
|
|
}
|
|
|
|
if (session.mcps && session.mcps.length > 0) {
|
|
// mcp configs
|
|
const mcpList: Record<string, McpHttpServerConfig> = {}
|
|
for (const mcpId of session.mcps) {
|
|
mcpList[mcpId] = {
|
|
type: 'http',
|
|
url: `http://${apiConfig.host}:${apiConfig.port}/v1/mcps/${mcpId}/mcp`,
|
|
headers: {
|
|
Authorization: `Bearer ${apiConfig.apiKey}`
|
|
}
|
|
}
|
|
}
|
|
options.mcpServers = mcpList
|
|
options.strictMcpConfig = true
|
|
}
|
|
|
|
if (lastAgentSessionId && !NO_RESUME_COMMANDS.some((cmd) => prompt.includes(cmd))) {
|
|
options.resume = lastAgentSessionId
|
|
// TODO: use fork session when we support branching sessions
|
|
// options.forkSession = true
|
|
}
|
|
|
|
logger.info('Starting Claude Code SDK query', {
|
|
prompt,
|
|
cwd: options.cwd,
|
|
model: options.model,
|
|
permissionMode: options.permissionMode,
|
|
maxTurns: options.maxTurns,
|
|
allowedTools: options.allowedTools,
|
|
resume: options.resume
|
|
})
|
|
|
|
const { stream: userInputStream, close: closeUserStream } = this.createUserMessageStream(
|
|
prompt,
|
|
abortController.signal
|
|
)
|
|
|
|
// Start async processing on the next tick so listeners can subscribe first
|
|
setImmediate(() => {
|
|
this.processSDKQuery(
|
|
userInputStream,
|
|
closeUserStream,
|
|
options,
|
|
aiStream,
|
|
errorChunks,
|
|
session.agent_id,
|
|
session.id
|
|
).catch((error) => {
|
|
logger.error('Unhandled Claude Code stream error', {
|
|
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
|
|
})
|
|
aiStream.emit('data', {
|
|
type: 'error',
|
|
error: error instanceof Error ? error : new Error(String(error))
|
|
})
|
|
})
|
|
})
|
|
|
|
return aiStream
|
|
}
|
|
|
|
private createUserMessageStream(initialPrompt: string, abortSignal: AbortSignal) {
|
|
const queue: Array<UserInputMessage | null> = []
|
|
const waiters: Array<(value: UserInputMessage | null) => void> = []
|
|
let closed = false
|
|
|
|
const flushWaiters = (value: UserInputMessage | null) => {
|
|
const resolve = waiters.shift()
|
|
if (resolve) {
|
|
resolve(value)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
const enqueue = (value: UserInputMessage | null) => {
|
|
if (closed) return
|
|
if (value === null) {
|
|
closed = true
|
|
}
|
|
if (!flushWaiters(value)) {
|
|
queue.push(value)
|
|
}
|
|
}
|
|
|
|
const close = () => {
|
|
if (closed) return
|
|
enqueue(null)
|
|
}
|
|
|
|
const onAbort = () => {
|
|
close()
|
|
}
|
|
|
|
if (abortSignal.aborted) {
|
|
close()
|
|
} else {
|
|
abortSignal.addEventListener('abort', onAbort, { once: true })
|
|
}
|
|
|
|
const iterator = (async function* () {
|
|
try {
|
|
while (true) {
|
|
let value: UserInputMessage | null
|
|
if (queue.length > 0) {
|
|
value = queue.shift() ?? null
|
|
} else if (closed) {
|
|
break
|
|
} else {
|
|
// Wait for next message or close signal
|
|
value = await new Promise<UserInputMessage | null>((resolve) => {
|
|
waiters.push(resolve)
|
|
})
|
|
}
|
|
|
|
if (value === null) {
|
|
break
|
|
}
|
|
|
|
yield value
|
|
}
|
|
} finally {
|
|
closed = true
|
|
abortSignal.removeEventListener('abort', onAbort)
|
|
while (waiters.length > 0) {
|
|
const resolve = waiters.shift()
|
|
resolve?.(null)
|
|
}
|
|
}
|
|
})()
|
|
|
|
enqueue({
|
|
type: 'user',
|
|
parent_tool_use_id: null,
|
|
session_id: '',
|
|
message: {
|
|
role: 'user',
|
|
content: initialPrompt
|
|
}
|
|
})
|
|
|
|
return {
|
|
stream: iterator,
|
|
enqueue,
|
|
close
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process SDK query and emit stream events
|
|
*/
|
|
private async processSDKQuery(
|
|
promptStream: AsyncIterable<UserInputMessage>,
|
|
closePromptStream: () => void,
|
|
options: Options,
|
|
stream: ClaudeCodeStream,
|
|
errorChunks: string[],
|
|
agentId: string,
|
|
sessionId: string
|
|
): Promise<void> {
|
|
const jsonOutput: SDKMessage[] = []
|
|
let hasCompleted = false
|
|
const startTime = Date.now()
|
|
const streamState = new ClaudeStreamState({ agentSessionId: sessionId })
|
|
|
|
try {
|
|
for await (const message of query({ prompt: promptStream, options })) {
|
|
if (hasCompleted) break
|
|
|
|
jsonOutput.push(message)
|
|
|
|
// Handle init message - merge builtin and SDK slash_commands
|
|
if (message.type === 'system' && message.subtype === 'init') {
|
|
const sdkSlashCommands = message.slash_commands || []
|
|
logger.info('Received init message with slash commands', {
|
|
sessionId,
|
|
commands: sdkSlashCommands
|
|
})
|
|
|
|
try {
|
|
// Get builtin + local slash commands from BaseService
|
|
const existingCommands = await sessionService.listSlashCommands('claude-code', agentId)
|
|
|
|
// Convert SDK slash_commands (string[]) to SlashCommand[] format
|
|
// Ensure all commands start with '/'
|
|
const sdkCommands = sdkSlashCommands.map((cmd) => {
|
|
const normalizedCmd = cmd.startsWith('/') ? cmd : `/${cmd}`
|
|
return {
|
|
command: normalizedCmd,
|
|
description: undefined
|
|
}
|
|
})
|
|
|
|
// Merge: existing commands (builtin + local) + SDK commands, deduplicate by command name
|
|
const commandMap = new Map<string, { command: string; description?: string }>()
|
|
|
|
for (const cmd of existingCommands) {
|
|
commandMap.set(cmd.command, cmd)
|
|
}
|
|
|
|
for (const cmd of sdkCommands) {
|
|
if (!commandMap.has(cmd.command)) {
|
|
commandMap.set(cmd.command, cmd)
|
|
}
|
|
}
|
|
|
|
const mergedCommands = Array.from(commandMap.values())
|
|
|
|
// Update session in database
|
|
await sessionService.updateSession(agentId, sessionId, {
|
|
slash_commands: mergedCommands
|
|
})
|
|
|
|
logger.info('Updated session with merged slash commands', {
|
|
sessionId,
|
|
existingCount: existingCommands.length,
|
|
sdkCount: sdkCommands.length,
|
|
totalCount: mergedCommands.length
|
|
})
|
|
} catch (error) {
|
|
logger.error('Failed to update session slash_commands', {
|
|
sessionId,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
})
|
|
}
|
|
}
|
|
|
|
const chunks = transformSDKMessageToStreamParts(message, streamState)
|
|
for (const chunk of chunks) {
|
|
stream.emit('data', {
|
|
type: 'chunk',
|
|
chunk
|
|
})
|
|
|
|
// Close prompt stream when SDK signals completion or error
|
|
if (chunk.type === 'finish' || chunk.type === 'error') {
|
|
logger.info('Closing prompt stream as SDK signaled completion', {
|
|
chunkType: chunk.type,
|
|
reason: chunk.type === 'finish' ? 'finished' : 'error_occurred'
|
|
})
|
|
closePromptStream()
|
|
logger.info('Prompt stream closed successfully')
|
|
}
|
|
}
|
|
}
|
|
|
|
const duration = Date.now() - startTime
|
|
|
|
logger.debug('SDK query completed successfully', {
|
|
duration,
|
|
messageCount: jsonOutput.length
|
|
})
|
|
|
|
stream.emit('data', {
|
|
type: 'complete'
|
|
})
|
|
} catch (error) {
|
|
if (hasCompleted) return
|
|
hasCompleted = true
|
|
|
|
const duration = Date.now() - startTime
|
|
const errorObj = error as any
|
|
const isAborted =
|
|
errorObj?.name === 'AbortError' ||
|
|
errorObj?.message?.includes('aborted') ||
|
|
options.abortController?.signal.aborted
|
|
|
|
if (isAborted) {
|
|
logger.info('SDK query aborted by client disconnect', { duration })
|
|
stream.emit('data', {
|
|
type: 'cancelled',
|
|
error: new Error('Request aborted by client')
|
|
})
|
|
return
|
|
}
|
|
|
|
errorChunks.push(errorObj instanceof Error ? errorObj.message : String(errorObj))
|
|
const errorMessage = errorChunks.join('\n\n')
|
|
logger.error('SDK query failed', {
|
|
duration,
|
|
error: errorObj instanceof Error ? { name: errorObj.name, message: errorObj.message } : String(errorObj),
|
|
stderr: errorChunks
|
|
})
|
|
|
|
stream.emit('data', {
|
|
type: 'error',
|
|
error: new Error(errorMessage)
|
|
})
|
|
} finally {
|
|
closePromptStream()
|
|
}
|
|
}
|
|
}
|
|
|
|
export default ClaudeCodeService
|