本章总览
Claude Code 启动时自带内置工具/命令/MCP 客户端,运行中 MCP 子系统还会动态发现新资源。合并态 Hook 负责在 React 层把「props 初始集」与「运行时 MCP 状态」合成 REPL 可用的单一视图,并保证与 headless runAgent 路径一致。四条 Hook 都很薄——真正业务逻辑在 assembleToolPool、mergeAndFilterTools、mergeClients 与 processQueueIfReady 等纯函数里;读本章时要同时打开 utils/ 与 tools.ts。
学完本章你应该能
- 解释 useMergedTools 为何 delegate 到 assembleToolPool + mergeAndFilterTools
- 说明 initialTools 在 dedup 时优先于 assembled 的含义
- 理解 useMergedCommands / useMergedClients 的 uniqBy 合并策略
- 画出 useQueueProcessor 与 QueryGuard、messageQueueManager 的协作
- 列举 processQueueIfReady 对 slash 命令与 batch 命令的不同处理
- 能在 REPL.tsx 找到 mergedTools 注入 query 循环的位置
核心概念(先读懂这些)
React Hook 只是 memo 壳
useMergedTools / useMergedCommands / useMergedClients 本质都是 useMemo 包裹的纯合并函数。这样 REPL 仅在依赖变化时重算工具池,而 runAgent、print.ts 可直接 import 同名的纯函数(mergeAndFilterTools、mergeClients)避免拉入 react/ink。改合并规则时,优先改 utils/toolPool.ts 或 export 的 mergeClients,再让 Hook 透传。
prompt-cache 稳定的排序
assembleToolPool 与 mergeAndFilterTools 都对 built-in 与 MCP 工具 分区排序:built-in 必须保持 contiguous prefix,因为服务端 claude_code_system_cache_policy 在最后一个 prefix-matched built-in 后打 cache breakpoint。若 flat sort 把 MCP 工具插进 built-in 中间,任意 MCP 增删都会 invalidate 下游 cache key——这是性能级约束,不是 cosmetic。
useSyncExternalStore 绕过 Ink 通知延迟
useQueueProcessor 用 useSyncExternalStore 订阅 QueryGuard 与 command queue module store,注释写明:React context 传播在 Ink 下可能丢通知,导致队列有项却不处理。这是 REPL 能在 turn 结束后可靠 drain 队列的关键。
建议学习步骤
- 阅读 tools.ts assembleToolPool 注释与实现
- 对照 utils/toolPool.ts mergeAndFilterTools 的 partition + coordinator filter
- 看 useMergedTools useMemo 依赖数组
- 读 useMergedCommands / mergeClients 的 uniqBy 逻辑
- 打开 useQueueProcessor 的 effect 条件与 processQueueIfReady 调用
- 在 REPL.tsx 搜索 useMergedTools 与 useQueueProcessor
常见误区
注意
useMergedTools 依赖数组含 replBridgeEnabled 占位变量——变更时确保不会意外 stale
注意
useQueueProcessor 不在 effect 里 reserve QueryGuard—— reservation 在 handlePromptSubmit 同步链完成
注意
队列里 subagent 寻址项会被 processQueueIfReady 跳过,否则会永久 stall 主线程队列
注意
Coordinator 模式下 mergeAndFilterTools 会 applyCoordinatorToolFilter,与 main.tsx headless 路径必须同步
在 REPL 数据流中的位置
REPL 初始化与运行期数据流:
props / bootstrap
→ combinedInitialTools + mcp.tools + mcp.commands + mcp.clients
↓
useMergedTools / useMergedCommands / useMergedClients
↓
query 循环、slash 命令、权限 UI、compact
↓
用户输入 / 任务通知 → messageQueueManager.enqueue
↓
useQueueProcessor(query 空闲且无 local JSX UI)
↓
processQueueIfReady → executeQueuedInput → handlePromptSubmit
useCanUseTool 消费 mergedTools 里的 permission 上下文;compact 注释写明 context.options.tools 含 MCP merge 结果。任何「工具列表不对」的 bug,应从此处四 Hook 向上追 props,向下追 assembleToolPool。
useMergedTools:双层合并
useMergedTools(initialTools, mcpTools, toolPermissionContext) 在 useMemo 内:
- 调用 assembleToolPool(toolPermissionContext, mcpTools) — 内置 getTools + MCP deny 过滤 + 按名 dedup(内置优先)
- 调用 mergeAndFilterTools(initialTools, assembled, mode) — 把 props 带来的 extra tools 叠加上去,再 coordinator 过滤
注释强调 initialTools 可能已含 getTools() 结果,与 assembled 重叠;uniqBy name 去重,initialTools 排在前面故优先。
Hook 本身约 40 行,是 REPL 与 runAgent 共享语义 的 React 适配层。headless main.tsx 直接调 mergeAndFilterTools,不走 Hook。
源码引用: src/hooks/useMergedTools.ts · 第 8–44 行(共 45 行)
8| /**
9| * React hook that assembles the full tool pool for the REPL.
10| *
11| * Uses assembleToolPool() (the shared pure function used by both REPL and runAgent)
12| * to combine built-in tools with MCP tools, applying deny rules and deduplication.
13| * Any extra initialTools are merged on top.
14| *
15| * @param initialTools - Extra tools to include (built-in + startup MCP from props).
16| * These are merged with the assembled pool and take precedence in deduplication.
17| * @param mcpTools - MCP tools discovered dynamically (from mcp state)
18| * @param toolPermissionContext - Permission context for filtering
19| */
20| export function useMergedTools(
21| initialTools: Tools,
22| mcpTools: Tools,
23| toolPermissionContext: ToolPermissionContext,
24| ): Tools {
25| let replBridgeEnabled = false
26| let replBridgeOutboundOnly = false
27| return useMemo(() => {
28| // assembleToolPool is the shared function that both REPL and runAgent use.
29| // It handles: getTools() + MCP deny-rule filtering + dedup + MCP CLI exclusion.
30| const assembled = assembleToolPool(toolPermissionContext, mcpTools)
31|
32| return mergeAndFilterTools(
33| initialTools,
34| assembled,
35| toolPermissionContext.mode,
36| )
37| }, [
38| initialTools,
39| mcpTools,
40| toolPermissionContext,
41| replBridgeEnabled,
42| replBridgeOutboundOnly,
43| ])
44| }
源码引用: src/tools.ts · 第 329–367 行(共 390 行)
329| /**
330| * Assemble the full tool pool for a given permission context and MCP tools.
331| *
332| * This is the single source of truth for combining built-in tools with MCP tools.
333| * Both REPL.tsx (via useMergedTools hook) and runAgent.ts (for coordinator workers)
334| * use this function to ensure consistent tool pool assembly.
335| *
336| * The function:
337| * 1. Gets built-in tools via getTools() (respects mode filtering)
338| * 2. Filters MCP tools by deny rules
339| * 3. Deduplicates by tool name (built-in tools take precedence)
340| *
341| * @param permissionContext - Permission context for filtering built-in tools
342| * @param mcpTools - MCP tools from appState.mcp.tools
343| * @returns Combined, deduplicated array of built-in and MCP tools
344| */
345| export function assembleToolPool(
346| permissionContext: ToolPermissionContext,
347| mcpTools: Tools,
348| ): Tools {
349| const builtInTools = getTools(permissionContext)
350|
351| // Filter out MCP tools that are in the deny list
352| const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)
353|
354| // Sort each partition for prompt-cache stability, keeping built-ins as a
355| // contiguous prefix. The server's claude_code_system_cache_policy places a
356| // global cache breakpoint after the last prefix-matched built-in tool; a flat
357| // sort would interleave MCP tools into built-ins and invalidate all downstream
358| // cache keys whenever an MCP tool sorts between existing built-ins. uniqBy
359| // preserves insertion order, so built-ins win on name conflict.
360| // Avoid Array.toSorted (Node 20+) — we support Node 18. builtInTools is
361| // readonly so copy-then-sort; allowedMcpTools is a fresh .filter() result.
362| const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
363| return uniqBy(
364| [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
365| 'name',
366| )
367| }
mergeAndFilterTools:排序与 Coordinator 过滤
utils/toolPool.ts 中的 mergeAndFilterTools 是 React-free 纯函数,print.ts 也可 import。
步骤:
uniqBy([...initialTools, ...assembled], 'name')— initial 优先partition(..., isMcpTool)— 拆 MCP 与 built-in- 各自 sort by name,再
[...builtIn, ...mcp]拼回(built-in prefix) - 若 COORDINATOR_MODE 且 isCoordinatorMode(),走 applyCoordinatorToolFilter
applyCoordinatorToolFilter 只允许 COORDINATOR_MODE_ALLOWED_TOOLS 集合内的工具,外加 PR activity subscription 后缀工具(subscribe_pr_activity 等),因为 coordinator 直接 orchestrate PR 订阅而非 delegate worker。
工程含义: 新增 MCP 工具默认对 coordinator 不可见,除非加入 allowlist 或符合 suffix 规则。
源码引用: src/utils/toolPool.ts · 第 16–41 行(共 80 行)
16| export function isPrActivitySubscriptionTool(name: string): boolean {
17| return PR_ACTIVITY_TOOL_SUFFIXES.some(suffix => name.endsWith(suffix))
18| }
19|
20| // Dead code elimination: conditional imports for feature-gated modules
21| /* eslint-disable @typescript-eslint/no-require-imports */
22| const coordinatorModeModule = feature('COORDINATOR_MODE')
23| ? (require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js'))
24| : null
25| /* eslint-enable @typescript-eslint/no-require-imports */
26|
27| /**
28| * Filters a tool array to the set allowed in coordinator mode.
29| * Shared between the REPL path (mergeAndFilterTools) and the headless
30| * path (main.tsx) so both stay in sync.
31| *
32| * PR activity subscription tools are always allowed since subscription
33| * management is orchestration.
34| */
35| export function applyCoordinatorToolFilter(tools: Tools): Tools {
36| return tools.filter(
37| t =>
38| COORDINATOR_MODE_ALLOWED_TOOLS.has(t.name) ||
39| isPrActivitySubscriptionTool(t.name),
40| )
41| }
源码引用: src/utils/toolPool.ts · 第 43–79 行(共 80 行)
43| /**
44| * Pure function that merges tool pools and applies coordinator mode filtering.
45| *
46| * Lives in a React-free file so print.ts can import it without pulling
47| * react/ink into the SDK module graph. The useMergedTools hook delegates
48| * to this function inside useMemo.
49| *
50| * @param initialTools - Extra tools to include (built-in + startup MCP from props).
51| * @param assembled - Tools from assembleToolPool (built-in + MCP, deduped).
52| * @param mode - The permission context mode.
53| * @returns Merged, deduplicated, and coordinator-filtered tool array.
54| */
55| export function mergeAndFilterTools(
56| initialTools: Tools,
57| assembled: Tools,
58| mode: ToolPermissionContext['mode'],
59| ): Tools {
60| // Merge initialTools on top - they take precedence in deduplication.
61| // initialTools may include built-in tools (from getTools() in REPL.tsx) which
62| // overlap with assembled tools. uniqBy handles this deduplication.
63| // Partition-sort for prompt-cache stability (same as assembleToolPool):
64| // built-ins must stay a contiguous prefix for the server's cache policy.
65| const [mcp, builtIn] = partition(
66| uniqBy([...initialTools, ...assembled], 'name'),
67| isMcpTool,
68| )
69| const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
70| const tools = [...builtIn.sort(byName), ...mcp.sort(byName)]
71|
72| if (feature('COORDINATOR_MODE') && coordinatorModeModule) {
73| if (coordinatorModeModule.isCoordinatorMode()) {
74| return applyCoordinatorToolFilter(tools)
75| }
76| }
77|
78| return tools
79| }
useMergedCommands:斜杠命令合并
useMergedCommands(initialCommands, mcpCommands) 逻辑极简:
- mcpCommands.length > 0 时:
uniqBy([...initialCommands, ...mcpCommands], 'name') - 否则直接返回 initialCommands
MCP 服务器可注册 commands(与 tools 不同),例如远程定义的 slash 扩展。uniqBy name 意味着同名命令 MCP 不会覆盖内置——数组顺序决定 precedence,与 tools 一致(initial 在前)。
REPL 把合并结果传给 slash 命令解析器与自动补全。若用户反映「MCP 命令不出现」,先查 mcp.commands 状态是否为空,再查 name 是否与内置冲突被 dedup 掉。
源码引用: src/hooks/useMergedCommands.ts · 第 1–15 行(共 16 行)
1| import uniqBy from 'lodash-es/uniqBy.js'
2| import { useMemo } from 'react'
3| import type { Command } from '../commands.js'
4|
5| export function useMergedCommands(
6| initialCommands: Command[],
7| mcpCommands: Command[],
8| ): Command[] {
9| return useMemo(() => {
10| if (mcpCommands.length > 0) {
11| return uniqBy([...initialCommands, ...mcpCommands], 'name')
12| }
13| return initialCommands
14| }, [initialCommands, mcpCommands])
15| }
useMergedClients 与 mergeClients
MCP 连接状态(failed / needs-auth / connected)在 UI 横幅、/mcp 面板中展示,需要合并 bootstrap clients 与运行时发现的 clients。
mergeClients(initial, mcp):
if initial && mcp?.length > 0 → uniqBy([...initial, ...mcp], 'name')
else → initial || []
mergeClients 导出供测试与非 React 代码复用;useMergedClients 仅 useMemo 包装。name 作为唯一键——同名 server 以 initial 列表优先。
关联: useMcpConnectivityStatus(notifs 章)消费 merged clients 数组,按 type/config.type 分桶发通知。
源码引用: src/hooks/useMergedClients.ts · 第 5–23 行(共 24 行)
5| export function mergeClients(
6| initialClients: MCPServerConnection[] | undefined,
7| mcpClients: readonly MCPServerConnection[] | undefined,
8| ): MCPServerConnection[] {
9| if (initialClients && mcpClients && mcpClients.length > 0) {
10| return uniqBy([...initialClients, ...mcpClients], 'name')
11| }
12| return initialClients || []
13| }
14|
15| export function useMergedClients(
16| initialClients: MCPServerConnection[] | undefined,
17| mcpClients: MCPServerConnection[] | undefined,
18| ): MCPServerConnection[] {
19| return useMemo(
20| () => mergeClients(initialClients, mcpClients),
21| [initialClients, mcpClients],
22| )
23| }
useQueueProcessor:何时 drain 队列
useQueueProcessor 接收:
| 参数 | 作用 |
|---|---|
| executeQueuedInput | 批量/单条执行队列命令(通常接 REPL executeUserInput) |
| hasActiveLocalJsxUI | local JSX 命令占用输入时为 true,阻塞处理 |
| queryGuard | QueryGuard 实例,subscribe/getSnapshot 供 useSyncExternalStore |
effect 条件(全部满足才 process):
!isQueryActive— 无进行中的 query turn!hasActiveLocalJsxUIqueueSnapshot.length > 0
满足时调用 processQueueIfReady({ executeInput: executeQueuedInput })。
注释解释 reservation 已移至 handlePromptSubmit 同步链:dequeue 后 executeQueuedInput → queryGuard.reserve() 在首个 await 前完成,effect 重入时 isQueryActive 已为 true,避免双 drain。
源码引用: src/hooks/useQueueProcessor.ts · 第 10–67 行(共 69 行)
10| type UseQueueProcessorParams = {
11| executeQueuedInput: (commands: QueuedCommand[]) => Promise<void>
12| hasActiveLocalJsxUI: boolean
13| queryGuard: QueryGuard
14| }
15|
16| /**
17| * Hook that processes queued commands when conditions are met.
18| *
19| * Uses a single unified command queue (module-level store). Priority determines
20| * processing order: 'now' > 'next' (user input) > 'later' (task notifications).
21| * The dequeue() function handles priority ordering automatically.
22| *
23| * Processing triggers when:
24| * - No query active (queryGuard — reactive via useSyncExternalStore)
25| * - Queue has items
26| * - No active local JSX UI blocking input
27| */
28| export function useQueueProcessor({
29| executeQueuedInput,
30| hasActiveLocalJsxUI,
31| queryGuard,
32| }: UseQueueProcessorParams): void {
33| // Subscribe to the query guard. Re-renders when a query starts or ends
34| // (or when reserve/cancelReservation transitions dispatching state).
35| const isQueryActive = useSyncExternalStore(
36| queryGuard.subscribe,
37| queryGuard.getSnapshot,
38| )
39|
40| // Subscribe to the unified command queue via useSyncExternalStore.
41| // This guarantees re-render when the store changes, bypassing
42| // React context propagation delays that cause missed notifications in Ink.
43| const queueSnapshot = useSyncExternalStore(
44| subscribeToCommandQueue,
45| getCommandQueueSnapshot,
46| )
47|
48| useEffect(() => {
49| if (isQueryActive) return
50| if (hasActiveLocalJsxUI) return
51| if (queueSnapshot.length === 0) return
52|
53| // Reservation is now owned by handlePromptSubmit (inside executeUserInput's
54| // try block). The sync chain executeQueuedInput → handlePromptSubmit →
55| // executeUserInput → queryGuard.reserve() runs before the first real await,
56| // so by the time React re-runs this effect (due to the dequeue-triggered
57| // snapshot change), isQueryActive is already true (dispatching) and the
58| // guard above returns early. handlePromptSubmit's finally releases the
59| // reservation via cancelReservation() (no-op if onQuery already ran end()).
60| processQueueIfReady({ executeInput: executeQueuedInput })
61| }, [
62| queueSnapshot,
63| isQueryActive,
64| executeQueuedInput,
65| hasActiveLocalJsxUI,
66| queryGuard,
67| ])
processQueueIfReady:优先级与 slash 单条处理
utils/queueProcessor.ts 实现队列 出队策略:
- 统一队列由 messageQueueManager 管理,dequeue() 按 priority 排序:
now>next(用户输入)>later(任务通知) - slash 命令(value 以
/开头)与 bash-mode 逐条 process,保证独立错误隔离与 progress UI - 其他同 mode 的非 slash 项可 batch drain,作为 QueuedCommand[] 一次传入 executeInput
- 跳过 subagent 寻址项,防止主线程 peek 到 subagent 通知后 processed:false 永久卡死
caller 负责每轮 command 完成后再次触发 processor 直到队列为空——useQueueProcessor 的 effect 依赖 queueSnapshot 变化自动重入。
query.ts 注释:某些 slash 应在 turn 结束后由 useQueueProcessor 触发,而非 mid-turn 插队。
源码引用: src/utils/queueProcessor.ts · 第 17–60 行(共 96 行)
17| /**
18| * Check if a queued command is a slash command (value starts with '/').
19| */
20| function isSlashCommand(cmd: QueuedCommand): boolean {
21| if (typeof cmd.value === 'string') {
22| return cmd.value.trim().startsWith('/')
23| }
24| // For ContentBlockParam[], check the first text block
25| for (const block of cmd.value) {
26| if (block.type === 'text') {
27| return block.text.trim().startsWith('/')
28| }
29| }
30| return false
31| }
32|
33| /**
34| * Processes commands from the queue.
35| *
36| * Slash commands (starting with '/') and bash-mode commands are processed
37| * one at a time so each goes through the executeInput path individually.
38| * Bash commands need individual processing to preserve per-command error
39| * isolation, exit codes, and progress UI. Other non-slash commands are
40| * batched: all items **with the same mode** as the highest-priority item
41| * are drained at once and passed as a single array to executeInput — each
42| * becomes its own user message with its own UUID. Different modes
43| * (e.g. prompt vs task-notification) are never mixed because they are
44| * treated differently downstream.
45| *
46| * The caller is responsible for ensuring no query is currently running
47| * and for calling this function again after each command completes
48| * until the queue is empty.
49| *
50| * @returns result with processed status
51| */
52| export function processQueueIfReady({
53| executeInput,
54| }: ProcessQueueParams): ProcessQueueResult {
55| // This processor runs on the REPL main thread between turns. Skip anything
56| // addressed to a subagent — an unfiltered peek() returning a subagent
57| // notification would set targetMode, dequeueAllMatching would find nothing
58| // matching that mode with agentId===undefined, and we'd return processed:
59| // false with the queue unchanged → the React effect never re-fires and any
60| // queued user prompt stalls permanently.
REPL 挂载点与 headless 对齐
REPL.tsx 典型调用:
const mergedTools = useMergedTools(combinedInitialTools, mcp.tools, toolPermissionContext);
useQueueProcessor({ executeQueuedInput, hasActiveLocalJsxUI, queryGuard });
combinedInitialTools 汇集 props.tools 与内置 getTools();mcp.tools 来自 AppState MCP 子状态。
main.tsx runAgent / coordinator worker 路径注释「mirrors useMergedTools filtering」——改 filter 时必须两边验证。cli/print.ts 亦提到 TUI useQueueProcessor 行为应对齐。
compact.ts 使用 permission-filtered tools 来源与 useMergedTools 相同,避免 compact 后模型看到的工具集与 REPL 不一致。
源码引用: src/screens/REPL.tsx · 第 811–811 行(共 7050 行)
811| </Text>
源码引用: src/tools.ts · 第 289–334 行(共 390 行)
289| // so the coordinator gets Task+TaskStop (via useMergedTools filtering) and
290| // workers get Bash/Read/Edit (via filterToolsForAgent filtering).
291| if (
292| feature('COORDINATOR_MODE') &&
293| coordinatorModeModule?.isCoordinatorMode()
294| ) {
295| simpleTools.push(AgentTool, TaskStopTool, getSendMessageTool())
296| }
297| return filterToolsByDenyRules(simpleTools, permissionContext)
298| }
299|
300| // Get all base tools and filter out special tools that get added conditionally
301| const specialTools = new Set([
302| ListMcpResourcesTool.name,
303| ReadMcpResourceTool.name,
304| SYNTHETIC_OUTPUT_TOOL_NAME,
305| ])
306|
307| const tools = getAllBaseTools().filter(tool => !specialTools.has(tool.name))
308|
309| // Filter out tools that are denied by the deny rules
310| let allowedTools = filterToolsByDenyRules(tools, permissionContext)
311|
312| // When REPL mode is enabled, hide primitive tools from direct use.
313| // They're still accessible inside REPL via the VM context.
314| if (isReplModeEnabled()) {
315| const replEnabled = allowedTools.some(tool =>
316| toolMatchesName(tool, REPL_TOOL_NAME),
317| )
318| if (replEnabled) {
319| allowedTools = allowedTools.filter(
320| tool => !REPL_ONLY_TOOLS.has(tool.name),
321| )
322| }
323| }
324|
325| const isEnabled = allowedTools.map(_ => _.isEnabled())
326| return allowedTools.filter((_, i) => isEnabled[i])
327| }
328|
329| /**
330| * Assemble the full tool pool for a given permission context and MCP tools.
331| *
332| * This is the single source of truth for combining built-in tools with MCP tools.
333| * Both REPL.tsx (via useMergedTools hook) and runAgent.ts (for coordinator workers)
334| * use this function to ensure consistent tool pool assembly.
源码目录(本主题相关文件)
强关联:utils/toolPool.ts、utils/queueProcessor.ts、utils/messageQueueManager.js、utils/QueryGuard.js、tools.ts(assembleToolPool)。
动手练习
- 在 REPL 连接一个 MCP server,观察 mergedTools 长度变化及 built-in 是否仍为 prefix
- 队列中连续 enqueue 两条 slash 命令,确认逐条执行而非 batch
- 阅读 COORDINATOR_MODE_ALLOWED_TOOLS,列出 coordinator 可见而 worker 不可见的工具差异
- 模拟 query 进行中 enqueue,确认 useQueueProcessor 不 drain;turn 结束后自动 drain
- 对照 mergeClients 与 /mcp UI,理解 failed vs needs-auth 通知分桶
本章小结与延伸
合并态 Hook = 把静态配置与动态 MCP/队列状态合成 REPL 单一真相源。下一章建议 notifs,理解横幅如何提示 MCP 连接失败等合并结果。 继续学习: