本章总览
query/stopHooks.ts 的 handleStopHooks 在 assistant turn 结束(无 further tool_use)时编排:缓存 fork 参数、job classifier、prompt suggestion、extract memories、auto-dream、Chicago MCP cleanup,并最终调用 utils/hooks.ts 的 executeStopHooks 运行用户配置的 Stop / SubagentStop Shell Hook。Hook 可 blocking(exit 2)注入 meta user message 迫使 loop continue,或 preventContinuation 终止 turn。本章是 Shell Hook 与 query 循环的交汇文档。
学完本章你应该能
- 描述 handleStopHooks 的 async generator 产出类型与 StopHookResult
- 解释 executeStopHooks 与 hook_event_name Stop 的输入字段
- 区分 preventContinuation 与 blockingErrors 两条 continue 路径
- 说明 teammate 路径的 TaskCompleted / TeammateIdle hooks
- 理解 API error 时 skip Stop hooks 的 death spiral 防护
核心概念(先读懂这些)
Stop hook 在 loop 中的精确插入点
仅在「assistant 完成且本轮无 tool_use」分支末尾调用 yield* handleStopHooks。tool 执行路径不走 Stop——agent 还在干活。API error(isApiErrorMessage)跳过 Stop,改 executeStopFailureHooks,避免 error → hook block → retry 死循环。
blocking vs preventContinuation
blockingError:hook exit 2,生成 meta user message,query continue 且 stopHookActive=true,模型会看到 hook feedback 再试。preventContinuation:hook 明确要求停止 turn(如用户脚本决定「不要再继续」),return stop_hook_prevented,无 blocking message 也可能发生。
stopHooks.ts ≠ utils/hooks.ts
stopHooks.ts 是 query 侧编排(background 任务、teammate、summary message)。utils/hooks.ts 是用户 Shell Hook 引擎(spawn、matcher、trust)。executeStopHooks 在 utils/hooks.ts;handleStopHooks 组装 REPLHookContext 并消费 generator yield。
建议学习步骤
- 阅读 handleStopHooks 签名与 stopHookContext 源码块 A
- 阅读 executeStopHooks 调用与 progress 消费循环源码块 B
- 阅读 blocking / preventContinuation 分支源码块 C
- 阅读 utils/hooks executeStopHooks 与 Stop hookInput 源码块 D
- 阅读 query.ts 调用点与 skip api error 逻辑
常见误区
注意
saveCacheSafeParams 仅 repl_main_thread / sdk——subagent 不可覆盖主 session 快照
注意
isBareMode 跳过 prompt suggestion / extract memories / auto-dream
注意
CHICAGO_MCP cleanup 仅主线程——subagent 释放 CU lock 会破坏主 thread
Turn 结束管线总览
assistant 无 tool_use
├─ [if isApiErrorMessage] executeStopFailureHooks → return completed
└─ yield* handleStopHooks(...)
├─ saveCacheSafeParams (main/sdk only)
├─ job classifier await (TEMPLATES + CLAUDE_JOB_DIR)
├─ [if !isBareMode] prompt suggestion / extract memories / auto-dream (fire-and-forget)
├─ CHICAGO_MCP cleanup (main thread)
├─ yield* executeStopHooks → Stop | SubagentStop
├─ [if teammate] TaskCompleted + TeammateIdle hooks
└─ return StopHookResult
query.ts 消费:
preventContinuation → return { reason: stop_hook_prevented }
blockingErrors.length → state continue (transition: stop_hook_blocking)
else → token budget / return completed
handleStopHooks 自身 yield StreamEvent | Message | progress | attachment——REPL 实时显示 hook 执行进度与 summary system message。
handleStopHooks 入口与 REPLHookContext
函数签名(L65-81)接受完整 turn 上下文:
- messagesForQuery + assistantMessages 合并进 stopHookContext.messages
- systemPrompt / userContext / systemContext / toolUseContext / querySource
saveCacheSafeParams(L96-98):仅 querySource === repl_main_thread | sdk。注释:subagents must not overwrite;/btw 与 side_question SDK 读此快照,不依赖 prompt suggestion feature。
Job classifier(L108-132):TEMPLATES feature + CLAUDE_JOB_DIR + repl_main_thread + !agentId。await Promise.race(classifier, 60s timeout)——保证 state.json 在 turn 返回前更新,避免 claude list 显示 stale。
Bare mode(L136-157):isBareMode() 跳过 prompt suggestion、extract memories、auto-dream——脚本 -p 调用不要在 shutdown 时 fork。
feature-gated require() 与 jobs/classifier、extractMemories 模式一致——外部 build 无模块图。
源码引用: src/query/stopHooks.ts · 第 65–98 行(共 474 行)
65| export async function* handleStopHooks(
66| messagesForQuery: Message[],
67| assistantMessages: AssistantMessage[],
68| systemPrompt: SystemPrompt,
69| userContext: { [k: string]: string },
70| systemContext: { [k: string]: string },
71| toolUseContext: ToolUseContext,
72| querySource: QuerySource,
73| stopHookActive?: boolean,
74| ): AsyncGenerator<
75| | StreamEvent
76| | RequestStartEvent
77| | Message
78| | TombstoneMessage
79| | ToolUseSummaryMessage,
80| StopHookResult
81| > {
82| const hookStartTime = Date.now()
83|
84| const stopHookContext: REPLHookContext = {
85| messages: [...messagesForQuery, ...assistantMessages],
86| systemPrompt,
87| userContext,
88| systemContext,
89| toolUseContext,
90| querySource,
91| }
92| // Only save params for main session queries — subagents must not overwrite.
93| // Outside the prompt-suggestion gate: the REPL /btw command and the
94| // side_question SDK control_request both read this snapshot, and neither
95| // depends on prompt suggestions being enabled.
96| if (querySource === 'repl_main_thread' || querySource === 'sdk') {
97| saveCacheSafeParams(createCacheSafeParams(stopHookContext))
98| }
源码引用: src/query/stopHooks.ts · 第 108–157 行(共 474 行)
108| if (
109| feature('TEMPLATES') &&
110| process.env.CLAUDE_JOB_DIR &&
111| querySource.startsWith('repl_main_thread') &&
112| !toolUseContext.agentId
113| ) {
114| // Full turn history — assistantMessages resets each queryLoop iteration,
115| // so tool calls from earlier iterations (Agent spawn, then summary) need
116| // messagesForQuery to be visible in the tool-call summary.
117| const turnAssistantMessages = stopHookContext.messages.filter(
118| (m): m is AssistantMessage => m.type === 'assistant',
119| )
120| const p = jobClassifierModule!
121| .classifyAndWriteState(process.env.CLAUDE_JOB_DIR, turnAssistantMessages)
122| .catch(err => {
123| logForDebugging(`[job] classifier error: ${errorMessage(err)}`, {
124| level: 'error',
125| })
126| })
127| await Promise.race([
128| p,
129| // eslint-disable-next-line no-restricted-syntax -- sleep() has no .unref(); timer must not block exit
130| new Promise<void>(r => setTimeout(r, 60_000).unref()),
131| ])
132| }
133| // --bare / SIMPLE: skip background bookkeeping (prompt suggestion,
134| // memory extraction, auto-dream). Scripted -p calls don't want auto-memory
135| // or forked agents contending for resources during shutdown.
136| if (!isBareMode()) {
137| // Inline env check for dead code elimination in external builds
138| if (!isEnvDefinedFalsy(process.env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION)) {
139| void executePromptSuggestion(stopHookContext)
140| }
141| if (
142| feature('EXTRACT_MEMORIES') &&
143| !toolUseContext.agentId &&
144| isExtractModeActive()
145| ) {
146| // Fire-and-forget in both interactive and non-interactive. For -p/SDK,
147| // print.ts drains the in-flight promise after flushing the response
148| // but before gracefulShutdownSync (see drainPendingExtraction).
149| void extractMemoriesModule!.executeExtractMemories(
150| stopHookContext,
151| toolUseContext.appendSystemMessage,
152| )
153| }
154| if (!toolUseContext.agentId) {
155| void executeAutoDream(stopHookContext, toolUseContext.appendSystemMessage)
156| }
157| }
executeStopHooks 消费循环
L175-295:调用 utils/hooks executeStopHooks,permissionMode 来自 appState.toolPermissionContext.mode。
循环跟踪:
- stopHookToolUseID / hookCount — 从 progress message 提取
- hookInfos[] — command + promptText + durationMs
- hookErrors[] — non_blocking_error / execution error stderr
- hasOutput — 任一 hook 产出 stdout/stderr
- preventedContinuation / stopReason
yield 类型:
- result.message — progress、attachment、userMessage(blocking)
- blocking 时 createUserMessage({ isMeta: true }) — UI 隐藏,summary 展示
preventContinuation(L269-279):yield createAttachmentMessage({ type: hook_stopped_continuation, hookName: Stop })
abort(L283-294):signal.aborted → yield userInterruptionMessage,return { preventContinuation: true }
Summary(L298-323):hookCount > 0 时 createStopHookSummaryMessage;hookErrors 时 addNotification stop-hook-error + ctrl+o 提示。
源码引用: src/query/stopHooks.ts · 第 175–295 行(共 474 行)
175| try {
176| const blockingErrors = []
177| const appState = toolUseContext.getAppState()
178| const permissionMode = appState.toolPermissionContext.mode
179|
180| const generator = executeStopHooks(
181| permissionMode,
182| toolUseContext.abortController.signal,
183| undefined,
184| stopHookActive ?? false,
185| toolUseContext.agentId,
186| toolUseContext,
187| [...messagesForQuery, ...assistantMessages],
188| toolUseContext.agentType,
189| )
190|
191| // Consume all progress messages and get blocking errors
192| let stopHookToolUseID = ''
193| let hookCount = 0
194| let preventedContinuation = false
195| let stopReason = ''
196| let hasOutput = false
197| const hookErrors: string[] = []
198| const hookInfos: StopHookInfo[] = []
199|
200| for await (const result of generator) {
201| if (result.message) {
202| yield result.message
203| // Track toolUseID from progress messages and count hooks
204| if (result.message.type === 'progress' && result.message.toolUseID) {
205| stopHookToolUseID = result.message.toolUseID
206| hookCount++
207| // Extract hook command and prompt text from progress data
208| const progressData = result.message.data as HookProgress
209| if (progressData.command) {
210| hookInfos.push({
211| command: progressData.command,
212| promptText: progressData.promptText,
213| })
214| }
215| }
216| // Track errors and output from attachments
217| if (result.message.type === 'attachment') {
218| const attachment = result.message.attachment
219| if (
220| 'hookEvent' in attachment &&
221| (attachment.hookEvent === 'Stop' ||
222| attachment.hookEvent === 'SubagentStop')
223| ) {
224| if (attachment.type === 'hook_non_blocking_error') {
225| hookErrors.push(
226| attachment.stderr || `Exit code ${attachment.exitCode}`,
227| )
228| // Non-blocking errors always have output
229| hasOutput = true
230| } else if (attachment.type === 'hook_error_during_execution') {
231| hookErrors.push(attachment.content)
232| hasOutput = true
233| } else if (attachment.type === 'hook_success') {
234| // Check if successful hook produced any stdout/stderr
235| if (
236| (attachment.stdout && attachment.stdout.trim()) ||
237| (attachment.stderr && attachment.stderr.trim())
238| ) {
239| hasOutput = true
240| }
241| }
242| // Extract per-hook duration for timing visibility.
243| // Hooks run in parallel; match by command + first unassigned entry.
244| if ('durationMs' in attachment && 'command' in attachment) {
245| const info = hookInfos.find(
246| i =>
247| i.command === attachment.command &&
248| i.durationMs === undefined,
249| )
250| if (info) {
251| info.durationMs = attachment.durationMs
252| }
253| }
254| }
255| }
256| }
257| if (result.blockingError) {
258| const userMessage = createUserMessage({
259| content: getStopHookMessage(result.blockingError),
260| isMeta: true, // Hide from UI (shown in summary message instead)
261| })
262| blockingErrors.push(userMessage)
263| yield userMessage
264| hasOutput = true
265| // Add to hookErrors so it appears in the summary
266| hookErrors.push(result.blockingError.blockingError)
267| }
268| // Check if hook wants to prevent continuation
269| if (result.preventContinuation) {
270| preventedContinuation = true
271| stopReason = result.stopReason || 'Stop hook prevented continuation'
272| // Create attachment to track the stopped continuation (for structured data)
273| yield createAttachmentMessage({
274| type: 'hook_stopped_continuation',
275| message: stopReason,
276| hookName: 'Stop',
277| toolUseID: stopHookToolUseID,
278| hookEvent: 'Stop',
279| })
280| }
281|
282| // Check if we were aborted during hook execution
283| if (toolUseContext.abortController.signal.aborted) {
284| logEvent('tengu_pre_stop_hooks_cancelled', {
285| queryChainId: toolUseContext.queryTracking
286| ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
287|
288| queryDepth: toolUseContext.queryTracking?.depth,
289| })
290| yield createUserInterruptionMessage({
291| toolUse: false,
292| })
293| return { blockingErrors: [], preventContinuation: true }
294| }
295| }
源码引用: src/query/stopHooks.ts · 第 297–332 行(共 474 行)
297| // Create summary system message if hooks ran
298| if (hookCount > 0) {
299| yield createStopHookSummaryMessage(
300| hookCount,
301| hookInfos,
302| hookErrors,
303| preventedContinuation,
304| stopReason,
305| hasOutput,
306| 'suggestion',
307| stopHookToolUseID,
308| )
309|
310| // Send notification about errors (shown in verbose/transcript mode via ctrl+o)
311| if (hookErrors.length > 0) {
312| const expandShortcut = getShortcutDisplay(
313| 'app:toggleTranscript',
314| 'Global',
315| 'ctrl+o',
316| )
317| toolUseContext.addNotification?.({
318| key: 'stop-hook-error',
319| text: `Stop hook error occurred \u00b7 ${expandShortcut} to see`,
320| priority: 'immediate',
321| })
322| }
323| }
324|
325| if (preventedContinuation) {
326| return { blockingErrors: [], preventContinuation: true }
327| }
328|
329| // Collect blocking errors from stop hooks
330| if (blockingErrors.length > 0) {
331| return { blockingErrors, preventContinuation: false }
332| }
utils/hooks.ts · Stop 事件执行
executeStopHooks(L3639-3697):
hookEvent = subagentId ? 'SubagentStop' : 'Stop'
hasHookForEvent 无配置则 early return——stopHooks.ts 循环不 yield progress。
hookInput 字段:
- createBaseHookInput(permissionMode) — session_id, transcript_path, cwd 等
- hook_event_name: 'Stop' | 'SubagentStop'
- stop_hook_active: 嵌套 Stop 标记
- last_assistant_message — 从 messages 最后 assistant 提取 text(hook 可 inspect 最终回复)
- SubagentStop 额外:agent_id, agent_transcript_path, agent_type
yield executeHooks({ hookInput, toolUseID, signal, timeoutMs, toolUseContext, messages })*
executeHooks 是用户 Shell Hook 总线:matcher、spawn command、prompt hook、function hook(仅 REPL Stop 允许)、exit code 2 → blockingError。
getStopHookMessage 格式化 blocking 反馈:Stop hook feedback:\n{blockingError}
与 mod-utils/shell-hooks 章交叉阅读:Stop 的 matcher 通常为空或 matchQuery undefined——每 turn 结束都跑。
源码引用: src/utils/hooks.ts · 第 3639–3697 行(共 5023 行)
3639| export async function* executeStopHooks(
3640| permissionMode?: string,
3641| signal?: AbortSignal,
3642| timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
3643| stopHookActive: boolean = false,
3644| subagentId?: AgentId,
3645| toolUseContext?: ToolUseContext,
3646| messages?: Message[],
3647| agentType?: string,
3648| requestPrompt?: (
3649| sourceName: string,
3650| toolInputSummary?: string | null,
3651| ) => (request: PromptRequest) => Promise<PromptResponse>,
3652| ): AsyncGenerator<AggregatedHookResult> {
3653| const hookEvent = subagentId ? 'SubagentStop' : 'Stop'
3654| const appState = toolUseContext?.getAppState()
3655| const sessionId = toolUseContext?.agentId ?? getSessionId()
3656| if (!hasHookForEvent(hookEvent, appState, sessionId)) {
3657| return
3658| }
3659|
3660| // Extract text content from the last assistant message so hooks can
3661| // inspect the final response without reading the transcript file.
3662| const lastAssistantMessage = messages
3663| ? getLastAssistantMessage(messages)
3664| : undefined
3665| const lastAssistantText = lastAssistantMessage
3666| ? extractTextContent(lastAssistantMessage.message.content, '\n').trim() ||
3667| undefined
3668| : undefined
3669|
3670| const hookInput: StopHookInput | SubagentStopHookInput = subagentId
3671| ? {
3672| ...createBaseHookInput(permissionMode),
3673| hook_event_name: 'SubagentStop',
3674| stop_hook_active: stopHookActive,
3675| agent_id: subagentId,
3676| agent_transcript_path: getAgentTranscriptPath(subagentId),
3677| agent_type: agentType ?? '',
3678| last_assistant_message: lastAssistantText,
3679| }
3680| : {
3681| ...createBaseHookInput(permissionMode),
3682| hook_event_name: 'Stop',
3683| stop_hook_active: stopHookActive,
3684| last_assistant_message: lastAssistantText,
3685| }
3686|
3687| // Trust check is now centralized in executeHooks()
3688| yield* executeHooks({
3689| hookInput,
3690| toolUseID: randomUUID(),
3691| signal,
3692| timeoutMs,
3693| toolUseContext,
3694| messages,
3695| requestPrompt,
3696| })
3697| }
源码引用: src/utils/hooks.ts · 第 1890–1896 行(共 5023 行)
1890| * Format a list of blocking errors from a Stop hook's configured commands.
1891| * @param blockingErrors Array of blocking errors from hooks
1892| * @returns Formatted message to give feedback to the model
1893| */
1894| export function getStopHookMessage(blockingError: HookBlockingError): string {
1895| return `Stop hook feedback:\n${blockingError.blockingError}`
1896| }
Teammate:TaskCompleted 与 TeammateIdle
Stop hooks 通过后,若 isTeammate()(L335-453):
- TaskCompleted — 对每个 in_progress 且 owner===teammateName 的 task 跑 executeTaskCompletedHooks
- TeammateIdle — executeTeammateIdleHooks(teammateName, teamName, ...)
行为 mirror Stop:yield progress、blocking meta user message、preventContinuation attachment、abort 检查。
toolUseID 分离: teammateHookToolUseID 从各自 progress 捕获——不用 Stop 的 stopHookToolUseID(注释 L341-342)。
返回优先级:
- teammatePreventedContinuation → { preventContinuation: true }
- teammateBlockingErrors.length → { blockingErrors, preventContinuation: false }
- else fall through → { blockingErrors: [], preventContinuation: false }
Swarm / teammate 模式下 Stop hook 与 idle hook 串联——主 Agent Stop 不替代 teammate 规则。
源码引用: src/query/stopHooks.ts · 第 334–400 行(共 474 行)
334| // After Stop hooks pass, run TeammateIdle and TaskCompleted hooks if this is a teammate
335| if (isTeammate()) {
336| const teammateName = getAgentName() ?? ''
337| const teamName = getTeamName() ?? ''
338| const teammateBlockingErrors: Message[] = []
339| let teammatePreventedContinuation = false
340| let teammateStopReason: string | undefined
341| // Each hook executor generates its own toolUseID — capture from progress
342| // messages (same pattern as stopHookToolUseID at L142), not the Stop ID.
343| let teammateHookToolUseID = ''
344|
345| // Run TaskCompleted hooks for any in-progress tasks owned by this teammate
346| const taskListId = getTaskListId()
347| const tasks = await listTasks(taskListId)
348| const inProgressTasks = tasks.filter(
349| t => t.status === 'in_progress' && t.owner === teammateName,
350| )
351|
352| for (const task of inProgressTasks) {
353| const taskCompletedGenerator = executeTaskCompletedHooks(
354| task.id,
355| task.subject,
356| task.description,
357| teammateName,
358| teamName,
359| permissionMode,
360| toolUseContext.abortController.signal,
361| undefined,
362| toolUseContext,
363| )
364|
365| for await (const result of taskCompletedGenerator) {
366| if (result.message) {
367| if (
368| result.message.type === 'progress' &&
369| result.message.toolUseID
370| ) {
371| teammateHookToolUseID = result.message.toolUseID
372| }
373| yield result.message
374| }
375| if (result.blockingError) {
376| const userMessage = createUserMessage({
377| content: getTaskCompletedHookMessage(result.blockingError),
378| isMeta: true,
379| })
380| teammateBlockingErrors.push(userMessage)
381| yield userMessage
382| }
383| // Match Stop hook behavior: allow preventContinuation/stopReason
384| if (result.preventContinuation) {
385| teammatePreventedContinuation = true
386| teammateStopReason =
387| result.stopReason || 'TaskCompleted hook prevented continuation'
388| yield createAttachmentMessage({
389| type: 'hook_stopped_continuation',
390| message: teammateStopReason,
391| hookName: 'TaskCompleted',
392| toolUseID: teammateHookToolUseID,
393| hookEvent: 'TaskCompleted',
394| })
395| }
396| if (toolUseContext.abortController.signal.aborted) {
397| return { blockingErrors: [], preventContinuation: true }
398| }
399| }
400| }
源码引用: src/query/stopHooks.ts · 第 402–453 行(共 474 行)
402| // Run TeammateIdle hooks
403| const teammateIdleGenerator = executeTeammateIdleHooks(
404| teammateName,
405| teamName,
406| permissionMode,
407| toolUseContext.abortController.signal,
408| )
409|
410| for await (const result of teammateIdleGenerator) {
411| if (result.message) {
412| if (result.message.type === 'progress' && result.message.toolUseID) {
413| teammateHookToolUseID = result.message.toolUseID
414| }
415| yield result.message
416| }
417| if (result.blockingError) {
418| const userMessage = createUserMessage({
419| content: getTeammateIdleHookMessage(result.blockingError),
420| isMeta: true,
421| })
422| teammateBlockingErrors.push(userMessage)
423| yield userMessage
424| }
425| // Match Stop hook behavior: allow preventContinuation/stopReason
426| if (result.preventContinuation) {
427| teammatePreventedContinuation = true
428| teammateStopReason =
429| result.stopReason || 'TeammateIdle hook prevented continuation'
430| yield createAttachmentMessage({
431| type: 'hook_stopped_continuation',
432| message: teammateStopReason,
433| hookName: 'TeammateIdle',
434| toolUseID: teammateHookToolUseID,
435| hookEvent: 'TeammateIdle',
436| })
437| }
438| if (toolUseContext.abortController.signal.aborted) {
439| return { blockingErrors: [], preventContinuation: true }
440| }
441| }
442|
443| if (teammatePreventedContinuation) {
444| return { blockingErrors: [], preventContinuation: true }
445| }
446|
447| if (teammateBlockingErrors.length > 0) {
448| return {
449| blockingErrors: teammateBlockingErrors,
450| preventContinuation: false,
451| }
452| }
453| }
query.ts 调用与 death spiral 防护
L1258-1306:
if (lastMessage?.isApiErrorMessage) {
void executeStopFailureHooks(lastMessage, toolUseContext)
return { reason: 'completed' }
}
const stopHookResult = yield* handleStopHooks(...)
if (stopHookResult.preventContinuation) {
return { reason: 'stop_hook_prevented' }
}
if (stopHookResult.blockingErrors.length > 0) {
state = { ..., messages: [...], stopHookActive: true, transition: { reason: 'stop_hook_blocking' } }
continue
}
Skip Stop on API error 注释 L1258-1261:rate limit / prompt-too-long / auth failure 时模型无真实回复——Stop hook 评估错误响应会导致 error → hook blocking → retry 螺旋。
hasAttemptedReactiveCompact 保留(L1292-1296):stop_hook_blocking continue 后若 reactive compact 已失败,不可 reset guard。
StopFailure 是独立 hook_event_name(utils/hooks executeStopFailureHooks),在 API error 路径 fire-and-forget。
源码引用: src/query.ts · 第 1258–1306 行(共 1730 行)
1258| // Skip stop hooks when the last message is an API error (rate limit,
1259| // prompt-too-long, auth failure, etc.). The model never produced a
1260| // real response — hooks evaluating it create a death spiral:
1261| // error → hook blocking → retry → error → …
1262| if (lastMessage?.isApiErrorMessage) {
1263| void executeStopFailureHooks(lastMessage, toolUseContext)
1264| return { reason: 'completed' }
1265| }
1266|
1267| const stopHookResult = yield* handleStopHooks(
1268| messagesForQuery,
1269| assistantMessages,
1270| systemPrompt,
1271| userContext,
1272| systemContext,
1273| toolUseContext,
1274| querySource,
1275| stopHookActive,
1276| )
1277|
1278| if (stopHookResult.preventContinuation) {
1279| return { reason: 'stop_hook_prevented' }
1280| }
1281|
1282| if (stopHookResult.blockingErrors.length > 0) {
1283| const next: State = {
1284| messages: [
1285| ...messagesForQuery,
1286| ...assistantMessages,
1287| ...stopHookResult.blockingErrors,
1288| ],
1289| toolUseContext,
1290| autoCompactTracking: tracking,
1291| maxOutputTokensRecoveryCount: 0,
1292| // Preserve the reactive compact guard — if compact already ran and
1293| // couldn't recover from prompt-too-long, retrying after a stop-hook
1294| // blocking error will produce the same result. Resetting to false
1295| // here caused an infinite loop: compact → still too long → error →
1296| // stop hook blocking → compact → … burning thousands of API calls.
1297| hasAttemptedReactiveCompact,
1298| maxOutputTokensOverride: undefined,
1299| pendingToolUseSummary: undefined,
1300| stopHookActive: true,
1301| turnCount,
1302| transition: { reason: 'stop_hook_blocking' },
1303| }
1304| state = next
1305| continue
1306| }
错误处理与 analytics
handleStopHooks try/catch(L456-472):
- logEvent('tengu_stop_hook_error', { duration, queryChainId, queryDepth })
- yield createSystemMessage(
Stop hook failed: ${error}, 'warning') — 用户可见、模型不可见 - return { blockingErrors: [], preventContinuation: false } — fail-open 不卡死 turn
Chicago MCP cleanup(L164-173):dynamic import cleanupComputerUseAfterTurn,失败 silent——dogfooding 非 critical path。
stopHookActive 传递: 嵌套 Stop 场景(hook 内又触发 stop)通过 stopHookActive 参数传入 executeStopHooks,写入 hookInput.stop_hook_active。
SubagentStop vs Stop: toolUseContext.agentId 存在时 utils/hooks 用 SubagentStop matcher 与 transcript 路径——settings 可分别配置。
源码引用: src/query/stopHooks.ts · 第 159–173 行(共 474 行)
159| // chicago MCP: auto-unhide + lock release at turn end.
160| // Main thread only — the CU lock is a process-wide module-level variable,
161| // so a subagent's stopHooks releasing it leaves the main thread's cleanup
162| // seeing isLockHeldLocally()===false → no exit notification, and unhides
163| // mid-turn. Subagents don't start CU sessions so this is a pure skip.
164| if (feature('CHICAGO_MCP') && !toolUseContext.agentId) {
165| try {
166| const { cleanupComputerUseAfterTurn } = await import(
167| '../utils/computerUse/cleanup.js'
168| )
169| await cleanupComputerUseAfterTurn(toolUseContext)
170| } catch {
171| // Failures are silent — this is dogfooding cleanup, not critical path
172| }
173| }
源码引用: src/query/stopHooks.ts · 第 456–472 行(共 474 行)
456| } catch (error) {
457| const durationMs = Date.now() - hookStartTime
458| logEvent('tengu_stop_hook_error', {
459| duration: durationMs,
460|
461| queryChainId: toolUseContext.queryTracking
462| ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
463| queryDepth: toolUseContext.queryTracking?.depth,
464| })
465| // Yield a system message that is not visible to the model for the user
466| // to debug their hook.
467| yield createSystemMessage(
468| `Stop hook failed: ${errorMessage(error)}`,
469| 'warning',
470| )
471| return { blockingErrors: [], preventContinuation: false }
472| }
与用户 settings 的映射
典型 Stop hook settings(概念):
{
"hooks": {
"Stop": [{
"matcher": "",
"hooks": [{ "type": "command", "command": ".claude/hooks/on-stop.sh" }]
}]
}
}
exit code 语义(utils/hooks 通用):
- 0 — 成功,stdout/stderr 可选 attach
- 2 — blocking,注入 meta user message,query continue
- 其他 — non_blocking_error,summary 展示
preventContinuation — hook JSON 输出或 function hook 返回 stopReason(executeHooks 内部解析),stopHooks.ts yield hook_stopped_continuation attachment。
调试 checklist:
- trust dialog 是否接受(shouldSkipHookDueToTrust)
- permissionMode 是否传入 hookInput
- REPL verbose ctrl+o 看 stop-hook-error notification
- stopHookActive 嵌套时 matcher 是否误配
完整 matcher / spawn / timeout 见 mod-utils/shell-hooks。
源码目录(本主题)
Stop 引擎在 utils/hooks.ts;点击 query.ts 查看 yield* 调用点。
动手练习
- 配置一条 Stop hook echo test,观察 progress 与 summary message
- 模拟 exit 2 blocking,确认 transition.reason === stop_hook_blocking
- 对比 PreToolUse 与 Stop 在 executeHooks 的 hookInput 差异
- 读 executeStopFailureHooks,说明与 Stop 的事件分工
本章小结与延伸
stopHooks = turn 结束的后置管线 + 用户 Stop 事件。Shell Hook 细节见 mod-utils/shell-hooks。 继续学习: