Claude Code 源码分析Claude Code 源码分析
首页
源码统计
系统架构
UML 图表
工具系统
CodeGraph
首页
源码统计
系统架构
UML 图表
工具系统
CodeGraph
  • 概览

    • Claude Code 源码分析
    • 源码统计
    • CodeGraph 图谱
  • 架构

    • 系统架构
    • UML 图表索引
    • 查询引擎
    • 核心流程
    • 消息系统
    • 状态管理
  • 功能模块

    • 工具系统
    • 斜杠命令
    • 服务层
    • MCP 协议
    • Skills 技能
    • 子代理系统
  • 分层深度

    • 入口层
    • UI / Ink 层
    • utils 基础设施
    • 桥接 / 远程
    • 上下文压缩
  • 原理与安全

    • 底层原理
    • 技术难点
    • 权限与安全
    • 内部机制
    • 遥测与分析
  • 深度专题

    • Hooks 系统
    • 插件系统
    • 记忆系统
    • API 通信层
    • Ink 终端 UI
    • 认证系统
    • 构建与发布
    • 术语表
  • 调用分析

    • 调用链分析
    • 核心文件索引
  • 模块详解

    • utils

      • 模块: utils
      • messages · 消息工厂与规范化
      • session-storage · JSONL 会话持久化
      • permissions · 工具权限决策
      • shell-hooks · 用户 Shell Hook 系统
    • components

      • 模块: components
      • REPL · 主屏编排
      • messages · 消息行渲染
      • PermissionRequest · 权限弹窗
      • PromptInput · 底部输入
    • services

      • 模块: services
      • api-claude · Anthropic API 流式与重试
      • mcp-client · MCP 连接与工具调用
      • compact · 上下文压缩与自动触发
      • analytics · GrowthBook、Datadog 与 1P 事件
    • tools

      • 模块: tools
      • tool-interface · Tool 契约与注册表
      • bash-tool · Shell 执行与权限
      • streaming-executor · 流式工具并发调度
      • agent-tool · 子 Agent 委派
    • commands

      • 模块: commands
      • command-registry · commands.ts 注册与分派
      • model-command · /model 模型选择
      • mcp-commands · /mcp 服务器管理
      • compact-memory-commands · /compact 与 /memory
    • ink

      • 模块: ink
      • Ink 渲染管线 · Screen 与终端输出
      • 终端事件 · resize、paste、stdin
      • Ink Hooks · 输入、搜索、终端状态
      • Ink 组件 · Box、Text、ScrollBox 原语
    • hooks

      • 模块: hooks
      • useCanUseTool · 权限 UI 接缝
      • 输入与快捷键 Hook
      • 合并态 Hook(MCP + 本地)
      • notifs 通知 Hook
    • bridge

      • 模块: bridge
      • repl-bridge · REPL 桥初始化与传输
      • bridge-messaging · 桥消息路由与入站处理
      • remote-bridge-core · env-less 核心与守护主循环
      • bridge-permissions-ui · 权限、API 与 TUI
    • cli

      • 模块: cli
      • Structured IO · NDJSON SDK 协议
      • CLI Transports · Session Ingress 传输层
      • CLI Handlers · 子命令懒加载实现
      • Update & Upload · 自更新与串行上传原语
    • screens

      • 模块: screens
      • REPL 屏 · Screen 类型与顶层路由
      • ResumeConversation · 会话恢复选择器
      • Doctor · 安装诊断全屏
    • entrypoints

      • 模块: entrypoints
      • cli-entrypoint · Bootstrap 与快路径
      • sdk-types · core / control / runtime 类型体系
      • mcp-entrypoint · MCP stdio 服务器
      • sandbox-types · 沙箱配置单一真相源
    • skills

      • 模块: skills
      • skills-loading · 磁盘加载与 bundled 注册表
      • bundled-skills · 内置 skill 与 initBundledSkills
      • mcp-skills · MCP prompt 转 skill
      • skill-tool-integration · SkillTool 与命令注册
    • types

      • 模块: types
      • message-types · Message 联合与 content blocks
      • tool-permission-types · Tool、Permission、Command 类型
      • api-sdk-types · API 与 Hooks 协议类型
      • misc-types · ids、plugin、generated 与其余类型
    • tasks

      • 模块: tasks
      • local-agent-task · 本地 Agent 与主会话后台化
      • remote-agent-task · 远程 CCR 与 In-Process Teammate
      • shell-workflow-tasks · Bash 后台、Workflow 与 stopTask
      • dream-monitor-tasks · Dream、Monitor MCP 与 pill 文案
    • keybindings

      • 模块: keybindings
      • keybinding-registry · 注册、Provider 与 useKeybinding
      • default-bindings · 默认键位表与平台差异
      • command-bindings · command:* 动态斜杠命令绑定
      • vim-bindings · Vim 模式与 keybindings 边界
    • memdir

      • 模块: memdir
      • memdir-core · 路径、加载与 MEMORY.md
      • memory-extraction · extractMemories 与 SessionMemory
      • memdir-commands · /memory、/remember 与命令集成
    • state

      • 模块: state
      • app-state-core · store、AppState 类型与 Provider
      • app-state-selectors · selectors 与 onChangeAppState
      • teammate-state · 队友视图与 swarm 状态
      • state-boundaries · bootstrap、sessionStorage、FileStateCache
    • query

      • 模块: query
      • query config 与 deps · 配置快照与依赖注入
      • query tokenBudget · +500k 自动续跑
      • query transitions · Continue / Terminal 状态机
      • query stopHooks · Stop 事件与 turn 结束编排
  • 模块详解(扩展)

    • messages · 消息工厂与规范化
    • session-storage · JSONL 会话持久化
    • permissions · 工具权限决策
    • shell-hooks · 用户 Shell Hook 系统
    • REPL · 主屏编排
    • messages · 消息行渲染
    • PermissionRequest · 权限弹窗
    • PromptInput · 底部输入
    • api-claude · Anthropic API 流式与重试
    • mcp-client · MCP 连接与工具调用
    • compact · 上下文压缩与自动触发
    • analytics · GrowthBook、Datadog 与 1P 事件
    • tool-interface · Tool 契约与注册表
    • bash-tool · Shell 执行与权限
    • streaming-executor · 流式工具并发调度
    • agent-tool · 子 Agent 委派
    • command-registry · commands.ts 注册与分派
    • model-command · /model 模型选择
    • mcp-commands · /mcp 服务器管理
    • compact-memory-commands · /compact 与 /memory
    • Ink 渲染管线 · Screen 与终端输出
    • 终端事件 · resize、paste、stdin
    • Ink Hooks · 输入、搜索、终端状态
    • Ink 组件 · Box、Text、ScrollBox 原语
    • useCanUseTool · 权限 UI 接缝
    • 输入与快捷键 Hook
    • 合并态 Hook(MCP + 本地)
    • notifs 通知 Hook
    • repl-bridge · REPL 桥初始化与传输
    • bridge-messaging · 桥消息路由与入站处理
    • remote-bridge-core · env-less 核心与守护主循环
    • bridge-permissions-ui · 权限、API 与 TUI
    • Structured IO · NDJSON SDK 协议
    • CLI Transports · Session Ingress 传输层
    • CLI Handlers · 子命令懒加载实现
    • Update & Upload · 自更新与串行上传原语
    • REPL 屏 · Screen 类型与顶层路由
    • ResumeConversation · 会话恢复选择器
    • Doctor · 安装诊断全屏
    • cli-entrypoint · Bootstrap 与快路径
    • sdk-types · core / control / runtime 类型体系
    • mcp-entrypoint · MCP stdio 服务器
    • sandbox-types · 沙箱配置单一真相源
    • skills-loading · 磁盘加载与 bundled 注册表
    • bundled-skills · 内置 skill 与 initBundledSkills
    • mcp-skills · MCP prompt 转 skill
    • skill-tool-integration · SkillTool 与命令注册
    • message-types · Message 联合与 content blocks
    • tool-permission-types · Tool、Permission、Command 类型
    • api-sdk-types · API 与 Hooks 协议类型
    • misc-types · ids、plugin、generated 与其余类型
    • local-agent-task · 本地 Agent 与主会话后台化
    • remote-agent-task · 远程 CCR 与 In-Process Teammate
    • shell-workflow-tasks · Bash 后台、Workflow 与 stopTask
    • dream-monitor-tasks · Dream、Monitor MCP 与 pill 文案
    • keybinding-registry · 注册、Provider 与 useKeybinding
    • default-bindings · 默认键位表与平台差异
    • command-bindings · command:* 动态斜杠命令绑定
    • vim-bindings · Vim 模式与 keybindings 边界
    • memdir-core · 路径、加载与 MEMORY.md
    • memory-extraction · extractMemories 与 SessionMemory
    • memdir-commands · /memory、/remember 与命令集成
    • app-state-core · store、AppState 类型与 Provider
    • app-state-selectors · selectors 与 onChangeAppState
    • teammate-state · 队友视图与 swarm 状态
    • state-boundaries · bootstrap、sessionStorage、FileStateCache
    • query config 与 deps · 配置快照与依赖注入
    • query tokenBudget · +500k 自动续跑
    • query transitions · Continue / Terminal 状态机
    • query stopHooks · Stop 事件与 turn 结束编排
  • 工具详解

    • tool-interface · Tool 契约与注册表
    • tool-permission-types · Tool、Permission、Command 类型
    • 工具: Bash
    • 工具: PowerShell
    • 工具: Agent
    • 工具: LSP
    • 工具: FileEdit
    • 工具: FileRead
    • 工具: Skill
    • 工具: WebFetch
    • 工具: MCP
    • 工具: SendMessage
    • 工具: FileWrite
    • 工具: Config
    • 工具: Grep
    • 工具: Brief
    • 工具: ExitPlanMode
    • 工具: ToolSearch
    • 工具: NotebookEdit
    • 工具: TaskOutput
    • 工具: WebSearch
    • 工具: ScheduleCron

本章总览

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。

建议学习步骤

  1. 阅读 handleStopHooks 签名与 stopHookContext 源码块 A
  2. 阅读 executeStopHooks 调用与 progress 消费循环源码块 B
  3. 阅读 blocking / preventContinuation 分支源码块 C
  4. 阅读 utils/hooks executeStopHooks 与 Stop hookInput 源码块 D
  5. 阅读 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):

  1. TaskCompleted — 对每个 in_progress 且 owner===teammateName 的 task 跑 executeTaskCompletedHooks
  2. 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:

  1. trust dialog 是否接受(shouldSkipHookDueToTrust)
  2. permissionMode 是否传入 hookInput
  3. REPL verbose ctrl+o 看 stop-hook-error notification
  4. stopHookActive 嵌套时 matcher 是否误配

完整 matcher / spawn / timeout 见 mod-utils/shell-hooks。

源码目录(本主题)

Stop 引擎在 utils/hooks.ts;点击 query.ts 查看 yield* 调用点。

动手练习

  1. 配置一条 Stop hook echo test,观察 progress 与 summary message
  2. 模拟 exit 2 blocking,确认 transition.reason === stop_hook_blocking
  3. 对比 PreToolUse 与 Stop 在 executeHooks 的 hookInput 差异
  4. 读 executeStopFailureHooks,说明与 Stop 的事件分工

本章小结与延伸

stopHooks = turn 结束的后置管线 + 用户 Stop 事件。Shell Hook 细节见 mod-utils/shell-hooks。 继续学习:

  • query 模块总览
  • shell-hooks
  • transitions
Prev
query transitions · Continue / Terminal 状态机