import 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, input?: string, effort?: EffortValue, ) => Promise setAppState: (updater: (prev: AppState) => AppState) => void onBeforeQuery?: (input: string, newMessages: Message[]) => Promise 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 helpers: PromptInputHelpers onInputChange: (value: string) => void setPastedContents: React.Dispatch< React.SetStateAction> > 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 { 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 { 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) } }