Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
22 kB · 610 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611import type { UUID } from 'crypto'import { logEvent } from 'src/services/analytics/index.js'import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/metadata.js'import { type Command, getCommandName, isCommandEnabled } from '../commands.js'import { selectableUserMessagesFilter } from '../components/MessageSelector.js'import type { SpinnerMode } from '../components/Spinner/types.js'import type { QuerySource } from '../constants/querySource.js'import { expandPastedTextRefs, parseReferences } from '../history.js'import type { CanUseToolFn } from '../hooks/useCanUseTool.js'import type { IDESelection } from '../hooks/useIdeSelection.js'import type { AppState } from '../state/AppState.js'import type { SetToolJSXFn } from '../Tool.js'import type { LocalJSXCommandOnDone } from '../types/command.js'import type { Message } from '../types/message.js'import { isValidImagePaste, type PromptInputMode, type QueuedCommand,} from '../types/textInputTypes.js'import { createAbortController } from './abortController.js'import type { PastedContent } from './config.js'import { logForDebugging } from './debug.js'import type { EffortValue } from './effort.js'import type { FileHistoryState } from './fileHistory.js'import { fileHistoryEnabled, fileHistoryMakeSnapshot } from './fileHistory.js'import { gracefulShutdownSync } from './gracefulShutdown.js'import { enqueue } from './messageQueueManager.js'import { resolveSkillModelOverride } from './model/model.js'import type { ProcessUserInputContext } from './processUserInput/processUserInput.js'import { processUserInput } from './processUserInput/processUserInput.js'import type { QueryGuard } from './QueryGuard.js'import { queryCheckpoint, startQueryProfile } from './queryProfiler.js'import { runWithWorkload } from './workloadContext.js'
function exit(): void { gracefulShutdownSync(0)}
type BaseExecutionParams = { queuedCommands?: QueuedCommand[] messages: Message[] mainLoopModel: string ideSelection: IDESelection | undefined querySource: QuerySource commands: Command[] queryGuard: QueryGuard /** * True when external loading (remote session, foregrounded background task) * is active. These don't route through queryGuard, so the queue check must * account for them separately. Omit (defaults to false) for the dequeue path * (executeQueuedInput) — dequeued items were already queued past this check. */ isExternalLoading?: boolean setToolJSX: SetToolJSXFn getToolUseContext: ( messages: Message[], newMessages: Message[], abortController: AbortController, mainLoopModel: string, ) => ProcessUserInputContext setUserInputOnProcessing: (prompt?: string) => void setAbortController: (abortController: AbortController | null) => void onQuery: ( newMessages: Message[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModel: string, onBeforeQuery?: (input: string, newMessages: Message[]) => Promise<boolean>, input?: string, effort?: EffortValue, ) => Promise<void> setAppState: (updater: (prev: AppState) => AppState) => void onBeforeQuery?: (input: string, newMessages: Message[]) => Promise<boolean> canUseTool?: CanUseToolFn}
/** * Parameters for core execution logic (no UI concerns). */type ExecuteUserInputParams = BaseExecutionParams & { resetHistory: () => void onInputChange: (value: string) => void}
export type PromptInputHelpers = { setCursorOffset: (offset: number) => void clearBuffer: () => void resetHistory: () => void}
export type HandlePromptSubmitParams = BaseExecutionParams & { // Direct user input path (set when called from onSubmit, absent for queue processor) input?: string mode?: PromptInputMode pastedContents?: Record<number, PastedContent> helpers: PromptInputHelpers onInputChange: (value: string) => void setPastedContents: React.Dispatch< React.SetStateAction<Record<number, PastedContent>> > abortController?: AbortController | null addNotification?: (notification: { key: string text: string priority: 'low' | 'medium' | 'high' | 'immediate' }) => void setMessages?: (updater: (prev: Message[]) => Message[]) => void streamMode?: SpinnerMode hasInterruptibleToolInProgress?: boolean uuid?: UUID /** * When true, input starting with `/` is treated as plain text. * Used for remotely-received messages (bridge/CCR) that should not * trigger local slash commands or skills. */ skipSlashCommands?: boolean}
export async function handlePromptSubmit( params: HandlePromptSubmitParams,): Promise<void> { const { helpers, queryGuard, isExternalLoading = false, commands, onInputChange, setPastedContents, setToolJSX, getToolUseContext, messages, mainLoopModel, ideSelection, setUserInputOnProcessing, setAbortController, onQuery, setAppState, onBeforeQuery, canUseTool, queuedCommands, uuid, skipSlashCommands, } = params
const { setCursorOffset, clearBuffer, resetHistory } = helpers
// Queue processor path: commands are pre-validated and ready to execute. // Skip all input validation, reference parsing, and queuing logic. if (queuedCommands?.length) { startQueryProfile() await executeUserInput({ queuedCommands, messages, mainLoopModel, ideSelection, querySource: params.querySource, commands, queryGuard, setToolJSX, getToolUseContext, setUserInputOnProcessing, setAbortController, onQuery, setAppState, onBeforeQuery, resetHistory, canUseTool, onInputChange, }) return }
const input = params.input ?? '' const mode = params.mode ?? 'prompt' const rawPastedContents = params.pastedContents ?? {}
// Images are only sent if their [Image #N] placeholder is still in the text. // Deleting the inline pill drops the image; orphaned entries are filtered here. const referencedIds = new Set(parseReferences(input).map(r => r.id)) const pastedContents = Object.fromEntries( Object.entries(rawPastedContents).filter( ([, c]) => c.type !== 'image' || referencedIds.has(c.id), ), )
const hasImages = Object.values(pastedContents).some(isValidImagePaste) if (input.trim() === '') { return }
// Handle exit commands by triggering the exit command instead of direct process.exit // Skip for remote bridge messages — "exit" typed on iOS shouldn't kill the local session if ( !skipSlashCommands && ['exit', 'quit', ':q', ':q!', ':wq', ':wq!'].includes(input.trim()) ) { // Trigger the exit command which will show the feedback dialog const exitCommand = commands.find(cmd => cmd.name === 'exit') if (exitCommand) { // Submit the /exit command instead - recursive call needs to be handled void handlePromptSubmit({ ...params, input: '/exit', }) } else { // Fallback to direct exit if exit command not found exit() } return }
// Parse references and replace with actual content early, before queueing // or immediate-command dispatch, so queued commands and immediate commands // both receive the expanded text from when it was submitted. const finalInput = expandPastedTextRefs(input, pastedContents) const pastedTextRefs = parseReferences(input).filter( r => pastedContents[r.id]?.type === 'text', ) const pastedTextCount = pastedTextRefs.length const pastedTextBytes = pastedTextRefs.reduce( (sum, r) => sum + (pastedContents[r.id]?.content.length ?? 0), 0, ) logEvent('tengu_paste_text', { pastedTextCount, pastedTextBytes })
// Handle local-jsx immediate commands (e.g., /config, /doctor) // Skip for remote bridge messages — slash commands from CCR clients are plain text if (!skipSlashCommands && finalInput.trim().startsWith('/')) { const trimmedInput = finalInput.trim() const spaceIndex = trimmedInput.indexOf(' ') const commandName = spaceIndex === -1 ? trimmedInput.slice(1) : trimmedInput.slice(1, spaceIndex) const commandArgs = spaceIndex === -1 ? '' : trimmedInput.slice(spaceIndex + 1).trim()
const immediateCommand = commands.find( cmd => cmd.immediate && isCommandEnabled(cmd) && (cmd.name === commandName || cmd.aliases?.includes(commandName) || getCommandName(cmd) === commandName), )
if ( immediateCommand && immediateCommand.type === 'local-jsx' && (queryGuard.isActive || isExternalLoading) ) { logEvent('tengu_immediate_command_executed', { commandName: immediateCommand.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, })
// Clear input onInputChange('') setCursorOffset(0) setPastedContents({}) clearBuffer()
const context = getToolUseContext( messages, [], createAbortController(), mainLoopModel, )
let doneWasCalled = false const onDone: LocalJSXCommandOnDone = (result, options) => { doneWasCalled = true // Use clearLocalJSX to explicitly clear the local JSX command setToolJSX({ jsx: null, shouldHidePromptInput: false, clearLocalJSX: true, }) if (result && options?.display !== 'skip' && params.addNotification) { params.addNotification({ key: `immediate-${immediateCommand.name}`, text: result, priority: 'immediate', }) } if (options?.nextInput) { if (options.submitNextInput) { enqueue({ value: options.nextInput, mode: 'prompt' }) } else { onInputChange(options.nextInput) } } }
const impl = await immediateCommand.load() const jsx = await impl.call(onDone, context, commandArgs)
// Skip if onDone already fired — prevents stuck isLocalJSXCommand // (see processSlashCommand.tsx local-jsx case for full mechanism). if (jsx && !doneWasCalled) { setToolJSX({ jsx, shouldHidePromptInput: false, isLocalJSXCommand: true, isImmediate: true, }) } return } }
if (queryGuard.isActive || isExternalLoading) { // Only allow prompt and bash mode commands to be queued if (mode !== 'prompt' && mode !== 'bash') { return }
// Interrupt the current turn when all executing tools have // interruptBehavior 'cancel' (e.g. SleepTool). if (params.hasInterruptibleToolInProgress) { logForDebugging( `[interrupt] Aborting current turn: streamMode=${params.streamMode}`, ) logEvent('tengu_cancel', { source: 'interrupt_on_submit' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, streamMode: params.streamMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) params.abortController?.abort('interrupt') }
// Enqueue with string value + raw pastedContents. Images will be resized // at execution time when processUserInput runs (not baked in here). enqueue({ value: finalInput.trim(), preExpansionValue: input.trim(), mode, pastedContents: hasImages ? pastedContents : undefined, skipSlashCommands, uuid, })
onInputChange('') setCursorOffset(0) setPastedContents({}) resetHistory() clearBuffer() return }
// Start query profiling for this query startQueryProfile()
// Construct a QueuedCommand from the direct user input so both paths // go through the same executeUserInput loop. This ensures images get // resized via processUserInput regardless of how the command arrives. const cmd: QueuedCommand = { value: finalInput, preExpansionValue: input, mode, pastedContents: hasImages ? pastedContents : undefined, skipSlashCommands, uuid, }
await executeUserInput({ queuedCommands: [cmd], messages, mainLoopModel, ideSelection, querySource: params.querySource, commands, queryGuard, setToolJSX, getToolUseContext, setUserInputOnProcessing, setAbortController, onQuery, setAppState, onBeforeQuery, resetHistory, canUseTool, onInputChange, })}
/** * Core logic for executing user input without UI side effects. * * All commands arrive as `queuedCommands`. First command gets full treatment * (attachments, ideSelection, pastedContents with image resizing). Commands 2-N * get `skipAttachments` to avoid duplicating turn-level context. */async function executeUserInput(params: ExecuteUserInputParams): Promise<void> { const { messages, mainLoopModel, ideSelection, querySource, queryGuard, setToolJSX, getToolUseContext, setUserInputOnProcessing, setAbortController, onQuery, setAppState, onBeforeQuery, resetHistory, canUseTool, queuedCommands, } = params
// Note: paste references are already processed before calling this function // (either in handlePromptSubmit before queuing, or before initial execution). // Always create a fresh abort controller — queryGuard guarantees no concurrent // executeUserInput call, so there's no prior controller to inherit. const abortController = createAbortController() setAbortController(abortController)
function makeContext(): ProcessUserInputContext { return getToolUseContext(messages, [], abortController, mainLoopModel) }
// Wrap in try-finally so the guard is released even if processUserInput // throws or onQuery is skipped. onQuery's finally calls queryGuard.end(), // which transitions running→idle; cancelReservation() below is a no-op in // that case (only acts on dispatching state). try { // Reserve the guard BEFORE processUserInput — processBashCommand awaits // BashTool.call() and processSlashCommand awaits getMessagesForSlashCommand, // so the guard must be active during those awaits to ensure concurrent // handlePromptSubmit calls queue (via the isActive check above) instead // of starting a second executeUserInput. This call is a no-op if the // guard is already in dispatching (legacy queue-processor path). queryGuard.reserve() queryCheckpoint('query_process_user_input_start')
const newMessages: Message[] = [] let shouldQuery = false let allowedTools: string[] | undefined let model: string | undefined let effort: EffortValue | undefined let nextInput: string | undefined let submitNextInput: boolean | undefined
// Iterate all commands uniformly. First command gets attachments + // ideSelection + pastedContents, rest skip attachments to avoid // duplicating turn-level context (IDE selection, todos, diffs). const commands = queuedCommands ?? []
// Compute the workload tag for this turn. queueProcessor can batch a // cron prompt with a same-tick human prompt; only tag when EVERY // command agrees on the same non-undefined workload — a human in the // mix is actively waiting. const firstWorkload = commands[0]?.workload const turnWorkload = firstWorkload !== undefined && commands.every(c => c.workload === firstWorkload) ? firstWorkload : undefined
// Wrap the entire turn (processUserInput loop + onQuery) in an // AsyncLocalStorage context. This is the ONLY way to correctly // propagate workload across await boundaries: void-detached bg agents // (executeForkedSlashCommand, AgentTool) capture the ALS context at // invocation time, and every await inside them resumes in that // context — isolated from the parent's continuation. A process-global // mutable slot would be clobbered at the detached closure's first // await by this function's synchronous return path. See state.ts. await runWithWorkload(turnWorkload, async () => { for (let i = 0; i < commands.length; i++) { const cmd = commands[i]! const isFirst = i === 0 const result = await processUserInput({ input: cmd.value, preExpansionInput: cmd.preExpansionValue, mode: cmd.mode, setToolJSX, context: makeContext(), pastedContents: isFirst ? cmd.pastedContents : undefined, messages, setUserInputOnProcessing: isFirst ? setUserInputOnProcessing : undefined, isAlreadyProcessing: !isFirst, querySource, canUseTool, uuid: cmd.uuid, ideSelection: isFirst ? ideSelection : undefined, skipSlashCommands: cmd.skipSlashCommands, bridgeOrigin: cmd.bridgeOrigin, isMeta: cmd.isMeta, skipAttachments: !isFirst, }) // Stamp origin here rather than threading another arg through // processUserInput → processUserInputBase → processTextPrompt → createUserMessage. // Derive origin from mode for task-notifications — mirrors the origin // derivation at messages.ts (case 'queued_command'); intentionally // does NOT mirror its isMeta:true so idle-dequeued notifications stay // visible in the transcript via UserAgentNotificationMessage. const origin = cmd.origin ?? (cmd.mode === 'task-notification' ? ({ kind: 'task-notification' } as const) : undefined) if (origin) { for (const m of result.messages) { if (m.type === 'user') m.origin = origin } } newMessages.push(...result.messages) if (isFirst) { shouldQuery = result.shouldQuery allowedTools = result.allowedTools model = result.model effort = result.effort nextInput = result.nextInput submitNextInput = result.submitNextInput } }
queryCheckpoint('query_process_user_input_end') if (fileHistoryEnabled()) { queryCheckpoint('query_file_history_snapshot_start') newMessages.filter(selectableUserMessagesFilter).forEach(message => { void fileHistoryMakeSnapshot( (updater: (prev: FileHistoryState) => FileHistoryState) => { setAppState(prev => ({ ...prev, fileHistory: updater(prev.fileHistory), })) }, message.uuid, ) }) queryCheckpoint('query_file_history_snapshot_end') }
if (newMessages.length) { // History is now added in the caller (onSubmit) for direct user submissions. // This ensures queued command processing (notifications, already-queued user input) // doesn't add to history, since those either shouldn't be in history or were // already added when originally queued. resetHistory() setToolJSX({ jsx: null, shouldHidePromptInput: false, clearLocalJSX: true, })
const primaryCmd = commands[0] const primaryMode = primaryCmd?.mode ?? 'prompt' const primaryInput = primaryCmd && typeof primaryCmd.value === 'string' ? primaryCmd.value : undefined const shouldCallBeforeQuery = primaryMode === 'prompt' await onQuery( newMessages, abortController, shouldQuery, allowedTools ?? [], model ? resolveSkillModelOverride(model, mainLoopModel) : mainLoopModel, shouldCallBeforeQuery ? onBeforeQuery : undefined, primaryInput, effort, ) } else { // Local slash commands that skip messages (e.g., /model, /theme). // Release the guard BEFORE clearing toolJSX to prevent spinner flash — // the spinner formula checks: (!toolJSX || showSpinner) && isLoading. // If we clear toolJSX while the guard is still reserved, spinner briefly // shows. The finally below also calls cancelReservation (no-op if idle). queryGuard.cancelReservation() setToolJSX({ jsx: null, shouldHidePromptInput: false, clearLocalJSX: true, }) resetHistory() setAbortController(null) }
// Handle nextInput from commands that want to chain (e.g., /discover activation) if (nextInput) { if (submitNextInput) { enqueue({ value: nextInput, mode: 'prompt' }) } else { params.onInputChange(nextInput) } } }) // end runWithWorkload — ALS context naturally scoped, no finally needed } finally { // Safety net: release the guard reservation if processUserInput threw // or onQuery was skipped. No-op if onQuery already ran (guard is idle // via end(), or running — cancelReservation only acts on dispatching). // This is the single source of truth for releasing the reservation; // useQueueProcessor no longer needs its own .finally(). queryGuard.cancelReservation() // Safety net: clear the placeholder if processUserInput produced no // messages or threw — otherwise it would stay visible until the next // turn's resetLoadingState. Harmless when onQuery ran: setMessages grew // displayedMessages past the baseline, so REPL.tsx already hid it. setUserInputOnProcessing(undefined) }}