Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
17 kB · 473 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474import { feature } from 'bun:bundle'import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'import { isExtractModeActive } from '../memdir/paths.js'import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent,} from '../services/analytics/index.js'import type { ToolUseContext } from '../Tool.js'import type { HookProgress } from '../types/hooks.js'import type { AssistantMessage, Message, RequestStartEvent, StopHookInfo, StreamEvent, TombstoneMessage, ToolUseSummaryMessage,} from '../types/message.js'import { createAttachmentMessage } from '../utils/attachments.js'import { logForDebugging } from '../utils/debug.js'import { errorMessage } from '../utils/errors.js'import type { REPLHookContext } from '../utils/hooks/postSamplingHooks.js'import { executeStopHooks, executeTaskCompletedHooks, executeTeammateIdleHooks, getStopHookMessage, getTaskCompletedHookMessage, getTeammateIdleHookMessage,} from '../utils/hooks.js'import { createStopHookSummaryMessage, createSystemMessage, createUserInterruptionMessage, createUserMessage,} from '../utils/messages.js'import type { SystemPrompt } from '../utils/systemPromptType.js'import { getTaskListId, listTasks } from '../utils/tasks.js'import { getAgentName, getTeamName, isTeammate } from '../utils/teammate.js'
/* eslint-disable @typescript-eslint/no-require-imports */const extractMemoriesModule = feature('EXTRACT_MEMORIES') ? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js')) : nullconst jobClassifierModule = feature('TEMPLATES') ? (require('../jobs/classifier.js') as typeof import('../jobs/classifier.js')) : null
/* eslint-enable @typescript-eslint/no-require-imports */
import type { QuerySource } from '../constants/querySource.js'import { executeAutoDream } from '../services/autoDream/autoDream.js'import { executePromptSuggestion } from '../services/PromptSuggestion/promptSuggestion.js'import { isBareMode, isEnvDefinedFalsy } from '../utils/envUtils.js'import { createCacheSafeParams, saveCacheSafeParams,} from '../utils/forkedAgent.js'
type StopHookResult = { blockingErrors: Message[] preventContinuation: boolean}
export async function* handleStopHooks( messagesForQuery: Message[], assistantMessages: AssistantMessage[], systemPrompt: SystemPrompt, userContext: { [k: string]: string }, systemContext: { [k: string]: string }, toolUseContext: ToolUseContext, querySource: QuerySource, stopHookActive?: boolean,): AsyncGenerator< | StreamEvent | RequestStartEvent | Message | TombstoneMessage | ToolUseSummaryMessage, StopHookResult> { const hookStartTime = Date.now()
const stopHookContext: REPLHookContext = { messages: [...messagesForQuery, ...assistantMessages], systemPrompt, userContext, systemContext, toolUseContext, querySource, } // Only save params for main session queries — subagents must not overwrite. // Outside the prompt-suggestion gate: the REPL /btw command and the // side_question SDK control_request both read this snapshot, and neither // depends on prompt suggestions being enabled. if (querySource === 'repl_main_thread' || querySource === 'sdk') { saveCacheSafeParams(createCacheSafeParams(stopHookContext)) }
// Template job classification: when running as a dispatched job, classify // state after each turn. Gate on repl_main_thread so background forks // (extract-memories, auto-dream) don't pollute the timeline with their own // assistant messages. Await the classifier so state.json is written before // the turn returns — otherwise `claude list` shows stale state for the gap. // Env key hardcoded (vs importing JOB_ENV_KEY from jobs/state) to match the // require()-gated jobs/ import pattern above; spawn.test.ts asserts the // string matches. if ( feature('TEMPLATES') && process.env.CLAUDE_JOB_DIR && querySource.startsWith('repl_main_thread') && !toolUseContext.agentId ) { // Full turn history — assistantMessages resets each queryLoop iteration, // so tool calls from earlier iterations (Agent spawn, then summary) need // messagesForQuery to be visible in the tool-call summary. const turnAssistantMessages = stopHookContext.messages.filter( (m): m is AssistantMessage => m.type === 'assistant', ) const p = jobClassifierModule! .classifyAndWriteState(process.env.CLAUDE_JOB_DIR, turnAssistantMessages) .catch(err => { logForDebugging(`[job] classifier error: ${errorMessage(err)}`, { level: 'error', }) }) await Promise.race([ p, // eslint-disable-next-line no-restricted-syntax -- sleep() has no .unref(); timer must not block exit new Promise<void>(r => setTimeout(r, 60_000).unref()), ]) } // --bare / SIMPLE: skip background bookkeeping (prompt suggestion, // memory extraction, auto-dream). Scripted -p calls don't want auto-memory // or forked agents contending for resources during shutdown. if (!isBareMode()) { // Inline env check for dead code elimination in external builds if (!isEnvDefinedFalsy(process.env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION)) { void executePromptSuggestion(stopHookContext) } if ( feature('EXTRACT_MEMORIES') && !toolUseContext.agentId && isExtractModeActive() ) { // Fire-and-forget in both interactive and non-interactive. For -p/SDK, // print.ts drains the in-flight promise after flushing the response // but before gracefulShutdownSync (see drainPendingExtraction). void extractMemoriesModule!.executeExtractMemories( stopHookContext, toolUseContext.appendSystemMessage, ) } if (!toolUseContext.agentId) { void executeAutoDream(stopHookContext, toolUseContext.appendSystemMessage) } }
// chicago MCP: auto-unhide + lock release at turn end. // Main thread only — the CU lock is a process-wide module-level variable, // so a subagent's stopHooks releasing it leaves the main thread's cleanup // seeing isLockHeldLocally()===false → no exit notification, and unhides // mid-turn. Subagents don't start CU sessions so this is a pure skip. if (feature('CHICAGO_MCP') && !toolUseContext.agentId) { try { const { cleanupComputerUseAfterTurn } = await import( '../utils/computerUse/cleanup.js' ) await cleanupComputerUseAfterTurn(toolUseContext) } catch { // Failures are silent — this is dogfooding cleanup, not critical path } }
try { const blockingErrors = [] const appState = toolUseContext.getAppState() const permissionMode = appState.toolPermissionContext.mode
const generator = executeStopHooks( permissionMode, toolUseContext.abortController.signal, undefined, stopHookActive ?? false, toolUseContext.agentId, toolUseContext, [...messagesForQuery, ...assistantMessages], toolUseContext.agentType, )
// Consume all progress messages and get blocking errors let stopHookToolUseID = '' let hookCount = 0 let preventedContinuation = false let stopReason = '' let hasOutput = false const hookErrors: string[] = [] const hookInfos: StopHookInfo[] = []
for await (const result of generator) { if (result.message) { yield result.message // Track toolUseID from progress messages and count hooks if (result.message.type === 'progress' && result.message.toolUseID) { stopHookToolUseID = result.message.toolUseID hookCount++ // Extract hook command and prompt text from progress data const progressData = result.message.data as HookProgress if (progressData.command) { hookInfos.push({ command: progressData.command, promptText: progressData.promptText, }) } } // Track errors and output from attachments if (result.message.type === 'attachment') { const attachment = result.message.attachment if ( 'hookEvent' in attachment && (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') ) { if (attachment.type === 'hook_non_blocking_error') { hookErrors.push( attachment.stderr || `Exit code ${attachment.exitCode}`, ) // Non-blocking errors always have output hasOutput = true } else if (attachment.type === 'hook_error_during_execution') { hookErrors.push(attachment.content) hasOutput = true } else if (attachment.type === 'hook_success') { // Check if successful hook produced any stdout/stderr if ( (attachment.stdout && attachment.stdout.trim()) || (attachment.stderr && attachment.stderr.trim()) ) { hasOutput = true } } // Extract per-hook duration for timing visibility. // Hooks run in parallel; match by command + first unassigned entry. if ('durationMs' in attachment && 'command' in attachment) { const info = hookInfos.find( i => i.command === attachment.command && i.durationMs === undefined, ) if (info) { info.durationMs = attachment.durationMs } } } } } if (result.blockingError) { const userMessage = createUserMessage({ content: getStopHookMessage(result.blockingError), isMeta: true, // Hide from UI (shown in summary message instead) }) blockingErrors.push(userMessage) yield userMessage hasOutput = true // Add to hookErrors so it appears in the summary hookErrors.push(result.blockingError.blockingError) } // Check if hook wants to prevent continuation if (result.preventContinuation) { preventedContinuation = true stopReason = result.stopReason || 'Stop hook prevented continuation' // Create attachment to track the stopped continuation (for structured data) yield createAttachmentMessage({ type: 'hook_stopped_continuation', message: stopReason, hookName: 'Stop', toolUseID: stopHookToolUseID, hookEvent: 'Stop', }) }
// Check if we were aborted during hook execution if (toolUseContext.abortController.signal.aborted) { logEvent('tengu_pre_stop_hooks_cancelled', { queryChainId: toolUseContext.queryTracking ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
queryDepth: toolUseContext.queryTracking?.depth, }) yield createUserInterruptionMessage({ toolUse: false, }) return { blockingErrors: [], preventContinuation: true } } }
// Create summary system message if hooks ran if (hookCount > 0) { yield createStopHookSummaryMessage( hookCount, hookInfos, hookErrors, preventedContinuation, stopReason, hasOutput, 'suggestion', stopHookToolUseID, )
// Send notification about errors (shown in verbose/transcript mode via ctrl+o) if (hookErrors.length > 0) { const expandShortcut = getShortcutDisplay( 'app:toggleTranscript', 'Global', 'ctrl+o', ) toolUseContext.addNotification?.({ key: 'stop-hook-error', text: `Stop hook error occurred \u00b7 ${expandShortcut} to see`, priority: 'immediate', }) } }
if (preventedContinuation) { return { blockingErrors: [], preventContinuation: true } }
// Collect blocking errors from stop hooks if (blockingErrors.length > 0) { return { blockingErrors, preventContinuation: false } }
// After Stop hooks pass, run TeammateIdle and TaskCompleted hooks if this is a teammate if (isTeammate()) { const teammateName = getAgentName() ?? '' const teamName = getTeamName() ?? '' const teammateBlockingErrors: Message[] = [] let teammatePreventedContinuation = false let teammateStopReason: string | undefined // Each hook executor generates its own toolUseID — capture from progress // messages (same pattern as stopHookToolUseID at L142), not the Stop ID. let teammateHookToolUseID = ''
// Run TaskCompleted hooks for any in-progress tasks owned by this teammate const taskListId = getTaskListId() const tasks = await listTasks(taskListId) const inProgressTasks = tasks.filter( t => t.status === 'in_progress' && t.owner === teammateName, )
for (const task of inProgressTasks) { const taskCompletedGenerator = executeTaskCompletedHooks( task.id, task.subject, task.description, teammateName, teamName, permissionMode, toolUseContext.abortController.signal, undefined, toolUseContext, )
for await (const result of taskCompletedGenerator) { if (result.message) { if ( result.message.type === 'progress' && result.message.toolUseID ) { teammateHookToolUseID = result.message.toolUseID } yield result.message } if (result.blockingError) { const userMessage = createUserMessage({ content: getTaskCompletedHookMessage(result.blockingError), isMeta: true, }) teammateBlockingErrors.push(userMessage) yield userMessage } // Match Stop hook behavior: allow preventContinuation/stopReason if (result.preventContinuation) { teammatePreventedContinuation = true teammateStopReason = result.stopReason || 'TaskCompleted hook prevented continuation' yield createAttachmentMessage({ type: 'hook_stopped_continuation', message: teammateStopReason, hookName: 'TaskCompleted', toolUseID: teammateHookToolUseID, hookEvent: 'TaskCompleted', }) } if (toolUseContext.abortController.signal.aborted) { return { blockingErrors: [], preventContinuation: true } } } }
// Run TeammateIdle hooks const teammateIdleGenerator = executeTeammateIdleHooks( teammateName, teamName, permissionMode, toolUseContext.abortController.signal, )
for await (const result of teammateIdleGenerator) { if (result.message) { if (result.message.type === 'progress' && result.message.toolUseID) { teammateHookToolUseID = result.message.toolUseID } yield result.message } if (result.blockingError) { const userMessage = createUserMessage({ content: getTeammateIdleHookMessage(result.blockingError), isMeta: true, }) teammateBlockingErrors.push(userMessage) yield userMessage } // Match Stop hook behavior: allow preventContinuation/stopReason if (result.preventContinuation) { teammatePreventedContinuation = true teammateStopReason = result.stopReason || 'TeammateIdle hook prevented continuation' yield createAttachmentMessage({ type: 'hook_stopped_continuation', message: teammateStopReason, hookName: 'TeammateIdle', toolUseID: teammateHookToolUseID, hookEvent: 'TeammateIdle', }) } if (toolUseContext.abortController.signal.aborted) { return { blockingErrors: [], preventContinuation: true } } }
if (teammatePreventedContinuation) { return { blockingErrors: [], preventContinuation: true } }
if (teammateBlockingErrors.length > 0) { return { blockingErrors: teammateBlockingErrors, preventContinuation: false, } } }
return { blockingErrors: [], preventContinuation: false } } catch (error) { const durationMs = Date.now() - hookStartTime logEvent('tengu_stop_hook_error', { duration: durationMs,
queryChainId: toolUseContext.queryTracking ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryDepth: toolUseContext.queryTracking?.depth, }) // Yield a system message that is not visible to the model for the user // to debug their hook. yield createSystemMessage( `Stop hook failed: ${errorMessage(error)}`, 'warning', ) return { blockingErrors: [], preventContinuation: false } }}