Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
20 kB · 552 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553import type { ToolUseBlock } from '@anthropic-ai/sdk/resources/index.mjs'import last from 'lodash-es/last.js'import { getSessionId, isSessionPersistenceDisabled,} from 'src/bootstrap/state.js'import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.js'import type { CanUseToolFn } from '../hooks/useCanUseTool.js'import { runTools } from '../services/tools/toolOrchestration.js'import { findToolByName, type Tool, type Tools } from '../Tool.js'import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js'import { FILE_EDIT_TOOL_NAME } from '../tools/FileEditTool/constants.js'import type { Input as FileReadInput } from '../tools/FileReadTool/FileReadTool.js'import { FILE_READ_TOOL_NAME, FILE_UNCHANGED_STUB,} from '../tools/FileReadTool/prompt.js'import { FILE_WRITE_TOOL_NAME } from '../tools/FileWriteTool/prompt.js'import type { Message } from '../types/message.js'import type { OrphanedPermission } from '../types/textInputTypes.js'import { logForDebugging } from './debug.js'import { isEnvTruthy } from './envUtils.js'import { isFsInaccessible } from './errors.js'import { getFileModificationTime, stripLineNumberPrefix } from './file.js'import { readFileSyncWithMetadata } from './fileRead.js'import { createFileStateCacheWithSizeLimit, type FileStateCache,} from './fileStateCache.js'import { isNotEmptyMessage, normalizeMessages } from './messages.js'import { expandPath } from './path.js'import type { inputSchema as permissionToolInputSchema, outputSchema as permissionToolOutputSchema,} from './permissions/PermissionPromptToolResultSchema.js'import type { ProcessUserInputContext } from './processUserInput/processUserInput.js'import { recordTranscript } from './sessionStorage.js'
export type PermissionPromptTool = Tool< ReturnType<typeof permissionToolInputSchema>, ReturnType<typeof permissionToolOutputSchema>>
// Small cache size for ask operations which typically access few files// during permission prompts or limited tool operationsconst ASK_READ_FILE_STATE_CACHE_SIZE = 10
/** * Checks if the result should be considered successful based on the last message. * Returns true if: * - Last message is assistant with text/thinking content * - Last message is user with only tool_result blocks * - Last message is the user prompt but the API completed with end_turn * (model chose to emit no content blocks) */export function isResultSuccessful( message: Message | undefined, stopReason: string | null = null,): message is Message { if (!message) return false
if (message.type === 'assistant') { const lastContent = last(message.message.content) return ( lastContent?.type === 'text' || lastContent?.type === 'thinking' || lastContent?.type === 'redacted_thinking' ) }
if (message.type === 'user') { // Check if all content blocks are tool_result type const content = message.message.content if ( Array.isArray(content) && content.length > 0 && content.every(block => 'type' in block && block.type === 'tool_result') ) { return true } }
// Carve-out: API completed (message_delta set stop_reason) but yielded // no assistant content — last(messages) is still this turn's prompt. // claude.ts:2026 recognizes end_turn-with-zero-content-blocks as // legitimate and passes through without throwing. Observed on // task_notification drain turns: model returns stop_reason=end_turn, // outputTokens=4, textContentLength=0 — it saw the subagent result // and decided nothing needed saying. Without this, QueryEngine emits // error_during_execution with errors[] = the entire process's // accumulated logError() buffer. Covers both string-content and // text-block-content user prompts, and any other non-passing shape. return stopReason === 'end_turn'}
// Track last sent time for tool progress messages per tool use ID// Keep only the last 100 entries to prevent unbounded growthconst MAX_TOOL_PROGRESS_TRACKING_ENTRIES = 100const TOOL_PROGRESS_THROTTLE_MS = 30000const toolProgressLastSentTime = new Map<string, number>()
export function* normalizeMessage(message: Message): Generator<SDKMessage> { switch (message.type) { case 'assistant': for (const _ of normalizeMessages([message])) { // Skip empty messages (e.g., "(no content)") that shouldn't be output to SDK if (!isNotEmptyMessage(_)) { continue } yield { type: 'assistant', message: _.message, parent_tool_use_id: null, session_id: getSessionId(), uuid: _.uuid, error: _.error, } } return case 'progress': if ( message.data.type === 'agent_progress' || message.data.type === 'skill_progress' ) { for (const _ of normalizeMessages([message.data.message])) { switch (_.type) { case 'assistant': // Skip empty messages (e.g., "(no content)") that shouldn't be output to SDK if (!isNotEmptyMessage(_)) { break } yield { type: 'assistant', message: _.message, parent_tool_use_id: message.parentToolUseID, session_id: getSessionId(), uuid: _.uuid, error: _.error, } break case 'user': yield { type: 'user', message: _.message, parent_tool_use_id: message.parentToolUseID, session_id: getSessionId(), uuid: _.uuid, timestamp: _.timestamp, isSynthetic: _.isMeta || _.isVisibleInTranscriptOnly, tool_use_result: _.mcpMeta ? { content: _.toolUseResult, ..._.mcpMeta } : _.toolUseResult, } break } } } else if ( message.data.type === 'bash_progress' || message.data.type === 'powershell_progress' ) { // Filter bash progress to send only one per minute // Only emit for Claude Code Remote for now if ( !isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) && !process.env.CLAUDE_CODE_CONTAINER_ID ) { break }
// Use parentToolUseID as the key since toolUseID changes for each progress message const trackingKey = message.parentToolUseID const now = Date.now() const lastSent = toolProgressLastSentTime.get(trackingKey) || 0 const timeSinceLastSent = now - lastSent
// Send if at least 30 seconds have passed since last update if (timeSinceLastSent >= TOOL_PROGRESS_THROTTLE_MS) { // Remove oldest entry if we're at capacity (LRU eviction) if ( toolProgressLastSentTime.size >= MAX_TOOL_PROGRESS_TRACKING_ENTRIES ) { const firstKey = toolProgressLastSentTime.keys().next().value if (firstKey !== undefined) { toolProgressLastSentTime.delete(firstKey) } }
toolProgressLastSentTime.set(trackingKey, now) yield { type: 'tool_progress', tool_use_id: message.toolUseID, tool_name: message.data.type === 'bash_progress' ? 'Bash' : 'PowerShell', parent_tool_use_id: message.parentToolUseID, elapsed_time_seconds: message.data.elapsedTimeSeconds, task_id: message.data.taskId, session_id: getSessionId(), uuid: message.uuid, } } } break case 'user': for (const _ of normalizeMessages([message])) { yield { type: 'user', message: _.message, parent_tool_use_id: null, session_id: getSessionId(), uuid: _.uuid, timestamp: _.timestamp, isSynthetic: _.isMeta || _.isVisibleInTranscriptOnly, tool_use_result: _.mcpMeta ? { content: _.toolUseResult, ..._.mcpMeta } : _.toolUseResult, } } return default: // yield nothing }}
export async function* handleOrphanedPermission( orphanedPermission: OrphanedPermission, tools: Tools, mutableMessages: Message[], processUserInputContext: ProcessUserInputContext,): AsyncGenerator<SDKMessage, void, unknown> { const persistSession = !isSessionPersistenceDisabled() const { permissionResult, assistantMessage } = orphanedPermission const { toolUseID } = permissionResult
if (!toolUseID) { return }
const content = assistantMessage.message.content let toolUseBlock: ToolUseBlock | undefined if (Array.isArray(content)) { for (const block of content) { if (block.type === 'tool_use' && block.id === toolUseID) { toolUseBlock = block as ToolUseBlock break } } }
if (!toolUseBlock) { return }
const toolName = toolUseBlock.name const toolInput = toolUseBlock.input
const toolDefinition = findToolByName(tools, toolName) if (!toolDefinition) { return }
// Create ToolUseBlock with the updated input if permission was allowed let finalInput = toolInput if (permissionResult.behavior === 'allow') { if (permissionResult.updatedInput !== undefined) { finalInput = permissionResult.updatedInput } else { logForDebugging( `Orphaned permission for ${toolName}: updatedInput is undefined, falling back to original tool input`, { level: 'warn' }, ) } } const finalToolUseBlock: ToolUseBlock = { ...toolUseBlock, input: finalInput, }
const canUseTool: CanUseToolFn = async () => ({ ...permissionResult, decisionReason: { type: 'mode', mode: 'default' as const, }, })
// Add the assistant message with tool_use to messages BEFORE executing // so the conversation history is complete (tool_use -> tool_result). // // On CCR resume, mutableMessages is seeded from the transcript and may already // contain this tool_use. Pushing again would make normalizeMessagesForAPI merge // same-ID assistants (concatenating content) and produce a duplicate tool_use // ID, which the API rejects with "tool_use ids must be unique". // // Check for the specific tool_use_id rather than message.id: streaming yields // each content block as a separate AssistantMessage sharing one message.id, so // a [text, tool_use] response lands as two entries. filterUnresolvedToolUses may // strip the tool_use entry but keep the text one; an id-based check would then // wrongly skip the push while runTools below still executes, orphaning the result. const alreadyPresent = mutableMessages.some( m => m.type === 'assistant' && Array.isArray(m.message.content) && m.message.content.some( b => b.type === 'tool_use' && 'id' in b && b.id === toolUseID, ), ) if (!alreadyPresent) { mutableMessages.push(assistantMessage) if (persistSession) { await recordTranscript(mutableMessages) } }
const sdkAssistantMessage: SDKMessage = { ...assistantMessage, session_id: getSessionId(), parent_tool_use_id: null, } as SDKMessage yield sdkAssistantMessage
// Execute the tool - errors are handled internally by runToolUse for await (const update of runTools( [finalToolUseBlock], [assistantMessage], canUseTool, processUserInputContext, )) { if (update.message) { mutableMessages.push(update.message) if (persistSession) { await recordTranscript(mutableMessages) }
const sdkMessage: SDKMessage = { ...update.message, session_id: getSessionId(), parent_tool_use_id: null, } as SDKMessage
yield sdkMessage } }}
// Create a function to extract read files from messagesexport function extractReadFilesFromMessages( messages: Message[], cwd: string, maxSize: number = ASK_READ_FILE_STATE_CACHE_SIZE,): FileStateCache { const cache = createFileStateCacheWithSizeLimit(maxSize)
// First pass: find all FileReadTool/FileWriteTool/FileEditTool uses in assistant messages const fileReadToolUseIds = new Map<string, string>() // toolUseId -> filePath const fileWriteToolUseIds = new Map< string, { filePath: string; content: string } >() // toolUseId -> { filePath, content } const fileEditToolUseIds = new Map<string, string>() // toolUseId -> filePath
for (const message of messages) { if ( message.type === 'assistant' && Array.isArray(message.message.content) ) { for (const content of message.message.content) { if ( content.type === 'tool_use' && content.name === FILE_READ_TOOL_NAME ) { // Extract file_path from the tool use input const input = content.input as FileReadInput | undefined // Ranged reads are not added to the cache. if ( input?.file_path && input?.offset === undefined && input?.limit === undefined ) { // Normalize to absolute path for consistent cache lookups const absolutePath = expandPath(input.file_path, cwd) fileReadToolUseIds.set(content.id, absolutePath) } } else if ( content.type === 'tool_use' && content.name === FILE_WRITE_TOOL_NAME ) { // Extract file_path and content from the Write tool use input const input = content.input as | { file_path?: string; content?: string } | undefined if (input?.file_path && input?.content) { // Normalize to absolute path for consistent cache lookups const absolutePath = expandPath(input.file_path, cwd) fileWriteToolUseIds.set(content.id, { filePath: absolutePath, content: input.content, }) } } else if ( content.type === 'tool_use' && content.name === FILE_EDIT_TOOL_NAME ) { // Edit's input has old_string/new_string, not the resulting content. // Track the path so the second pass can read current disk state. const input = content.input as { file_path?: string } | undefined if (input?.file_path) { const absolutePath = expandPath(input.file_path, cwd) fileEditToolUseIds.set(content.id, absolutePath) } } } } }
// Second pass: find corresponding tool results and extract content for (const message of messages) { if (message.type === 'user' && Array.isArray(message.message.content)) { for (const content of message.message.content) { if (content.type === 'tool_result' && content.tool_use_id) { // Handle Read tool results const readFilePath = fileReadToolUseIds.get(content.tool_use_id) if ( readFilePath && typeof content.content === 'string' && // Dedup stubs contain no file content — the earlier real Read // already cached it. Chronological last-wins would otherwise // overwrite the real entry with stub text. !content.content.startsWith(FILE_UNCHANGED_STUB) ) { // Remove system-reminder blocks from the content const processedContent = content.content.replace( /<system-reminder>[\s\S]*?<\/system-reminder>/g, '', )
// Extract the actual file content from the tool result // Tool results for text files contain line numbers, we need to strip those const fileContent = processedContent .split('\n') .map(stripLineNumberPrefix) .join('\n') .trim()
// Cache the file content with the message timestamp if (message.timestamp) { const timestamp = new Date(message.timestamp).getTime() cache.set(readFilePath, { content: fileContent, timestamp, offset: undefined, limit: undefined, }) } }
// Handle Write tool results - use content from the tool input const writeToolData = fileWriteToolUseIds.get(content.tool_use_id) if (writeToolData && message.timestamp) { const timestamp = new Date(message.timestamp).getTime() cache.set(writeToolData.filePath, { content: writeToolData.content, timestamp, offset: undefined, limit: undefined, }) }
// Handle Edit tool results — post-edit content isn't in the // tool_use input (only old_string/new_string) nor fully in the // result (only a snippet). Read from disk now, using actual mtime // so getChangedFiles's mtime check passes on the next turn. // // Callers seed the cache once at process start (print.ts --resume, // Cowork cold-restart per turn), so disk content at extraction time // IS the post-edit state. No dedup: processing every Edit preserves // last-wins semantics when Read/Write interleave (Edit→Read→Edit). const editFilePath = fileEditToolUseIds.get(content.tool_use_id) if (editFilePath && content.is_error !== true) { try { const { content: diskContent } = readFileSyncWithMetadata(editFilePath) cache.set(editFilePath, { content: diskContent, timestamp: getFileModificationTime(editFilePath), offset: undefined, limit: undefined, }) } catch (e: unknown) { if (!isFsInaccessible(e)) { throw e } // File deleted or inaccessible since the Edit — skip } } } } } }
return cache}
/** * Extract the top-level CLI tools used in BashTool calls from message history. * Returns a deduplicated set of command names (e.g. 'vercel', 'aws', 'git'). */export function extractBashToolsFromMessages(messages: Message[]): Set<string> { const tools = new Set<string>() for (const message of messages) { if ( message.type === 'assistant' && Array.isArray(message.message.content) ) { for (const content of message.message.content) { if (content.type === 'tool_use' && content.name === BASH_TOOL_NAME) { const { input } = content if ( typeof input !== 'object' || input === null || !('command' in input) ) continue const cmd = extractCliName( typeof input.command === 'string' ? input.command : undefined, ) if (cmd) { tools.add(cmd) } } } } } return tools}
const STRIPPED_COMMANDS = new Set(['sudo'])
/** * Extract the actual CLI name from a bash command string, skipping * env var assignments (e.g. `FOO=bar vercel` → `vercel`) and prefixes * in STRIPPED_COMMANDS. */function extractCliName(command: string | undefined): string | undefined { if (!command) return undefined const tokens = command.trim().split(/\s+/) for (const token of tokens) { if (/^[A-Za-z_]\w*=/.test(token)) continue if (STRIPPED_COMMANDS.has(token)) continue return token } return undefined}