Skip to content

Chapter 13: Hooks Layer — Business Logic Bridge

What You'll Learn

By the end of this chapter, you will be able to:

  • Explain the architectural purpose of src/hooks/ and why it exists as a distinct layer between REPL.tsx and the underlying engine systems
  • Read useCanUseTool.tsx with awareness that it is React Compiler output, understand its three-strategy dispatch, and trace a permission decision from tool invocation to resolved PermissionDecisionReason
  • Describe how useLogMessages.ts solves the high-frequency event problem through batching, and explain why non-text events bypass the batch queue
  • Follow a slash command through useCommandQueue.ts from user input to completed execution, including the queue serialization guarantee
  • Understand what state useTextInput.ts owns, how it models multi-line cursor movement, and why IME composition handling matters for CJK input
  • Describe the two completion modes of useTypeahead.tsx — command completion and file path completion — and explain how they share a common return shape
  • Explain what useReplBridge.tsx synchronizes, in which direction, and for what category of consumer
  • Navigate src/hooks/toolPermission/ and match each file to its corresponding strategy in useCanUseTool

13.1 The Architecture of the Hooks Layer

Chapter 11 introduced REPL.tsx as the application layer — a roughly 3000-line React component that assembles Claude Code's interactive session. Chapter 11 also deferred one question deliberately: REPL.tsx consumes state and callbacks from a long list of custom hooks, but what are those hooks actually doing? That question is the subject of this chapter.

The src/hooks/ directory contains approximately 100 files. The word "approximately" is intentional: the count fluctuates as new features add hooks and refactoring merges them. What does not fluctuate is the directory's purpose. Every file in it exists to solve one of three problems that REPL.tsx would face if it tried to handle everything inline.

The first problem is isolation. REPL.tsx would be unreadable if it contained, directly in its body, the event-subscription logic for the QueryEngine, the debounced filesystem reads for file completion, the IPC proxying for swarm permission delegation, and the cursor-position arithmetic for multi-line input editing. Each of these concerns is independent of the others and deserves its own module. Extracting each concern into a hook gives it a clear boundary and a name.

The second problem is bridging. The systems underneath REPL.tsx — the QueryEngine, the command registry, the permission system — are not React constructs. They are plain TypeScript classes and functions that have no awareness of React's rendering model, its state update mechanism, or its component lifecycle. A hook is the standard React mechanism for wrapping a non-React system so that it participates in the reactive data flow. Each hook in src/hooks/ is, at its core, a bridge from one of those external systems into React state.

The third problem is reuse. A hook that encapsulates input state management can be used by the main REPL and by a headless test harness without either consumer knowing the other exists. A hook that encapsulates permission decisions can be injected as a dependency into any component that needs it. This is the standard advantage of hooks over inline component logic, and the Claude Code codebase exploits it throughout.

A useful mental model for the relationship between layers is the TCP/IP analogy. REPL.tsx is the application layer: it declares what data it needs and what actions it should be able to trigger. The hooks are the transport layer: they handle how data arrives from non-React sources and how actions are dispatched back to those sources. The underlying systems — QueryEngine, command registry, permission engine — are the network layer: they do the real work and are indifferent to React's existence.

Understanding this three-layer model is the prerequisite for reading any individual hook. When you see a useEffect that subscribes to an event emitter, you are looking at the bridge join. When you see a useState that stores normalized message objects, you are looking at the transport layer's output format. When you see a callback function returned from a hook, you are looking at the action dispatch path.


13.2 useCanUseTool.tsx — The Permission Decision Hub

src/hooks/useCanUseTool.tsx

This hook is the reactive face of the entire permission system described in Chapter 7. Chapter 7 explained the data types — PermissionMode, PermissionBehavior, PermissionDecisionReason — and the decision engine function hasPermissionsToUseTool. This chapter explains how those types and that function get wired into a React hook that REPL.tsx actually calls.

Before reading the source file, there is an important caveat: useCanUseTool.tsx is a React Compiler output file, not hand-written source code. The React Compiler transforms component and hook code to insert memoization automatically, replacing hand-written useMemo and useCallback with compiler-generated equivalents. The concrete sign of this is that the compiled file contains calls to _c() — a generated function that manages a compile-time-computed slot cache. When you open useCanUseTool.tsx and see constructs like const $ = _c(14) and if ($[0] !== someValue) { $[0] = someValue; $[1] = result; }, you are looking at this cache infrastructure, not at the hook's logic. Read past it; the actual permission logic is still there, just interspersed with cache bookkeeping.

The hook's return value is a function of type CanUseToolFn. This function signature is:

typescript
// The contract that useCanUseTool produces
type CanUseToolFn = (
  tool: Tool,
  input: unknown,
  context: ToolUseContext
) => Promise<PermissionDecision>

This function is injected into ToolUseContext and then called by the agentic loop (Chapter 5) every time a tool is about to execute. The hook does not decide permissions at render time; it produces a stable function that makes decisions on demand, asynchronously, when a tool invocation actually occurs.

13.2.1 Three-Strategy Dispatch

The central design of useCanUseTool is a strategy selection that happens at the top of the CanUseToolFn it returns. Before any permission logic runs, the hook examines the current execution context to determine which of three strategies applies.

typescript
// Strategy selection at the top of CanUseToolFn (conceptual reconstruction)
async function canUseTool(tool, input, context): Promise<PermissionDecision> {
  if (isCoordinatorContext(context)) {
    // Strategy 1: This is the coordinator agent — proxy to the human leader
    return coordinatorPermissions.request(tool, input, context)
  }

  if (isSwarmWorker(context)) {
    // Strategy 2: This is a swarm worker — proxy via IPC to the coordinator
    return swarmPermissions.request(tool, input, context)
  }

  // Strategy 3: Normal interactive REPL — ask the user
  return interactivePermissions.request(tool, input, context)
}

Strategy 1 applies when the current agent instance is acting as a coordinator in a multi-agent topology. The coordinator does not make autonomous permission decisions; instead it proxies the question to the human operator who launched the overall session. The details of this proxying are implemented in src/hooks/toolPermission/coordinatorPermissions.ts, covered in Section 13.8.

Strategy 2 applies when the current agent instance is a swarm worker — one of potentially many parallel agents executing sub-tasks under a coordinator's direction. The swarm worker has no direct terminal connection to a human; it must tunnel the permission question through IPC to whichever agent does. This is implemented in src/hooks/toolPermission/swarmPermissions.ts.

Strategy 3 is the common case: a normal interactive Claude Code session with one agent, one terminal, and one user. The interactive strategy renders a permission dialog in the terminal UI and waits for the user to respond.

13.2.2 Interactive Decision Flow

The interactive strategy is the most complex because it must coordinate three independent resolution paths that can race each other.

The first step is to call tool.checkPermissions(input, context). This is a method that every tool implements; it runs the tool's own pre-flight check using the rules engine from Chapter 7. If the check returns allow or deny immediately — because a matching rule exists in settings.json — the function returns right there without any UI interaction.

If checkPermissions returns ask, three paths open simultaneously using Promise.race.

typescript
// The three-path race in interactive mode (conceptual)
const decision = await Promise.race([
  // Path A: Speculative classifier (auto-approve low-risk commands)
  speculativeClassifier(tool, input, { timeoutMs: 2000 }),

  // Path B: Wait for the user to interact with the dialog
  waitForUserDialog(tool, input),

  // Path C: Session abort signal
  waitForAbort(context.abortSignal),
])

Path A is the speculative classifier. For commands that are statistically low-risk — read operations, queries, commands that match a known-safe pattern — a lightweight classifier evaluates the call and can return allow automatically within a 2000ms window. If the classifier fires within the timeout, the user never sees a dialog for that call. The reasoning behind this design is ergonomic: if every git status and cat README.md required explicit user confirmation, the tool would be unusable. The speculative classifier absorbs the noise and surfaces only the calls that genuinely need a human judgment.

Path B is the interactive dialog. A permission request object is pushed into a React state queue that PermissionDialog (Chapter 12) renders. The user sees the dialog, presses a key, and a callback resolves this path's promise with either allow-once, allow-always, deny, or one of the persist variants. If allow-always or a persist-deny is chosen, the decision is written back to settings.json as a new rule so that future calls of the same pattern skip the dialog entirely.

Path C is the abort path. If the query is interrupted while the dialog is open — the user pressed Escape, or the session is shutting down — the abort signal resolves this path and the tool call is cancelled cleanly.

Whichever path resolves first wins the race. Path B is cancelled if Path A fires first (the dialog that was briefly visible is dismissed automatically). Path A is cancelled if Path B resolves first (the classifier result is discarded).

13.2.3 PermissionDecisionReason in the Interactive Path

Chapter 7 listed all eleven PermissionDecisionReason variants. The interactive path of useCanUseTool is responsible for producing a subset of them. After the race resolves, the hook records which path won and stores that as the reason:

settings-allow / settings-deny — produced when checkPermissions resolved before the race even started, because an explicit rule in settings.json matched.

speculative-allow — produced when Path A fired and the classifier approved the call.

interactive-allow-once / interactive-allow-always / interactive-deny — produced when the user made an explicit choice in Path B.

worker-proxy — produced in Strategy 2, when the swarm worker tunneled the decision through IPC and received the final answer from the coordinator.

coordinator-allow / coordinator-deny — produced in Strategy 1, when the coordinator forwarded the question to the human leader and received a response.

These reasons are not decorative. They are written into the session's permission audit log and are available to callers of the QueryEngine's result message. A programmatic caller that invokes Claude Code headlessly can inspect SDKResultMessage.permissionDenials and see, for each denial, exactly which reason code caused it.


13.3 useLogMessages.ts — The Message Stream Bridge

src/hooks/useLogMessages.ts

The QueryEngine (Chapter 9) communicates its progress through a stream of StreamEvent objects. These events are not React-aware: they are emitted by a plain Node.js EventEmitter and have no knowledge of component lifecycle, state updates, or rendering. useLogMessages is the bridge that subscribes to this emitter and converts its events into the LogMessage[] array that REPL.tsx passes to MessageList.

13.3.1 Subscription and Lifecycle

The hook's useEffect establishes the subscription when the component mounts and tears it down when it unmounts. The lifecycle is controlled by an AbortController signal that the hook creates internally, so that if the parent component re-renders with a new QueryEngine instance, the old subscription is cleanly terminated before the new one starts.

typescript
// Subscription lifecycle in useLogMessages
useEffect(() => {
  const controller = new AbortController()

  const handler = (event: StreamEvent) => {
    if (controller.signal.aborted) return
    receiveEvent(event)
  }

  queryEngine.addEventListener('streamEvent', handler)

  return () => {
    controller.abort()
    queryEngine.removeEventListener('streamEvent', handler)
  }
}, [queryEngine])

The double guard — both the AbortController and the removeEventListener — handles the race condition where an event fires after the effect cleanup begins but before the listener is fully removed. In React's concurrent mode, this race is possible, and handling it correctly prevents state updates on unmounted components.

13.3.2 The Batching Problem

The fundamental performance challenge for this hook is token streaming speed. When Claude streams a response at full generation speed, text_delta events can arrive at 50 or more per second. A naive implementation that calls setState for each event would schedule 50 re-renders per second, each of which traverses the component tree, runs Yoga layout, and writes to the differential output buffer. The overhead accumulates; the terminal stutters.

The solution is event batching. Text delta events — the high-frequency ones — are not immediately converted to state updates. Instead they are accumulated in a useRef buffer, and the state update is deferred until the next animation frame (or a 16ms timer for environments where requestAnimationFrame is not available).

typescript
// Batching mechanism for high-frequency text_delta events
const pendingText = useRef<string>('')
const batchHandle = useRef<ReturnType<typeof requestAnimationFrame> | null>(null)

function flushPendingText() {
  if (pendingText.current.length === 0) return
  const accumulated = pendingText.current
  pendingText.current = ''
  batchHandle.current = null

  setMessages(prev => appendToLastAssistantMessage(prev, accumulated))
}

// When a text_delta arrives:
function receiveTextDelta(delta: string) {
  pendingText.current += delta
  if (batchHandle.current === null) {
    batchHandle.current = requestAnimationFrame(flushPendingText)
  }
}

The effect is that a burst of 30 tokens arriving within a single 16ms frame is collapsed into one setState call and one re-render. The user cannot perceive the batching because it is sub-frame; the rendering appears continuous.

Non-text events — tool_use_start, tool_result, message_start, error events — bypass the batch queue and flush immediately. These events represent semantic boundaries: the user wants to see that a tool has started running, or that an error occurred, without waiting for the next frame. The cost of immediate flushing for these events is acceptable because they are low frequency.

13.3.3 Message Normalization in applyStreamEvent

The core of the hook is the applyStreamEvent function, which implements a state machine over the messages array. Each event type maps to a specific transformation:

A message_start event pushes a new empty AssistantMessage onto the array. A text_delta event finds the last AssistantMessage and appends to its text content. A tool_use_start event pushes a new ToolUseMessage with an empty input object. A tool_use_input_delta event finds the ToolUseMessage by ID and appends to its serialized JSON input. A tool_result event pushes a new ToolResultMessage keyed to the corresponding tool use ID. An error event pushes a SystemMessage with the error details.

This design means that the messages array is always in a structurally consistent state, even mid-stream. The ToolUseMessage for a partially-arrived JSON input is valid and renderable — it just shows incomplete JSON. The AssistantMessage mid-stream shows what has arrived so far. There is no "buffering until complete" phase; every intermediate state is displayable.

The hook also handles TombstoneMessage injection. When a /compact command has been processed and older messages have been removed from the conversation, a tombstone is inserted at the compaction point so that the user can see where history was truncated. The hook receives a special internal event when compaction occurs and inserts the tombstone into the message array at the correct position.


13.4 useCommandQueue.ts — Slash Command Dispatch

src/hooks/useCommandQueue.ts

The command system (Chapter 8) defines commands as registry entries with a run method. useCommandQueue is the hook that connects user input to that registry in a way that handles concurrency correctly.

13.4.1 The Queuing Rationale

Concurrency is the central design problem for slash command execution. Consider what happens if the user types /compact (which can take several seconds to complete) and then immediately types /model before the first command finishes. Without a queue, both commands start executing simultaneously. The /compact command is modifying the message list while /model is trying to render a model selection dialog into the same message list. The interaction between them is undefined and can produce visual corruption.

The queue ensures that commands execute serially. When a command is enqueued, it is placed into a Promise chain. The next command in the queue does not start until the current command's promise resolves. The user sees each command complete in order, which is the behavior they expect even if they typed the second command quickly.

13.4.2 Execution Pipeline

REPL.tsx calls the enqueueCommand function returned by this hook every time the user submits input that begins with /. The pipeline inside the hook:

First, findCommand(input) searches the command registry. The search handles both exact matches (/help) and prefix matches with arguments (/model claude-3-5-sonnet). If no command is found, a SystemMessage is pushed into the message list noting the unrecognized command.

Second, if a command is found, a new promise is appended to the execution queue. The command does not start immediately; it waits for any currently executing command to complete.

Third, when the command's turn arrives, command.run(args, context) is called with the parsed argument string and the current ToolUseContext. Commands that query the model, modify settings, or perform filesystem operations do so inside this call.

Fourth, LocalJSXCommand type commands — those that render interactive UI rather than just executing imperatively — return a React element from run. The hook injects this element into the message list at the correct position, where it is rendered like any other message. The /model command and the /config command work this way: they push a rendered component into the conversation that the user can interact with using keyboard navigation.

Fifth, after run resolves, the hook calls notifyCommandLifecycle(uuid, 'completed'), which updates any status indicators and allows the queue to advance to the next command.

typescript
// Simplified queue mechanism
async function executeNext() {
  const cmd = queue.current.shift()
  if (!cmd) { isExecuting.current = false; return }

  isExecuting.current = true
  try {
    const result = await cmd.command.run(cmd.args, cmd.context)
    if (isJSXElement(result)) {
      injectMessageElement(cmd.uuid, result)
    }
    notifyCommandLifecycle(cmd.uuid, 'completed')
  } catch (err) {
    notifyCommandLifecycle(cmd.uuid, 'error')
  } finally {
    executeNext()  // process next in queue
  }
}

The finally block ensures that the queue always advances, even if a command throws. A failing command produces an error notification but does not block subsequent commands from running.


13.5 useTextInput.ts — Input Box State Machine

src/hooks/useTextInput.ts

The PromptInput component (Chapter 11) renders the text entry area at the bottom of the REPL. useTextInput owns all of the mutable state for that component, separating the "what the input contains" from the "how it is rendered." Every key press that modifies the input is processed here.

13.5.1 State Shape

The state managed by this hook is richer than a simple value: string. A multi-line text editor in a terminal needs to track more than just the string content:

typescript
// State shape of useTextInput
type TextInputState = {
  value: string             // current text content, may contain newlines
  cursorPosition: number    // character index of the cursor within value
  selectionStart: number    // selection anchor (Shift+arrow usage)
  selectionEnd: number      // selection head
  history: string[]         // submitted input history for this session
  historyIndex: number      // -1 means current draft, >=0 means browsing history
  isComposing: boolean      // true during IME composition (CJK input)
  yankBuffer: string        // text cut by Ctrl+K, available for Ctrl+Y paste
}

The separation of cursorPosition from the string content is necessary because terminal input is character-indexed, not pixel-positioned. Moving the cursor left means decrementing cursorPosition; moving it to the beginning of the current line means scanning backward through value for the previous newline character. All of this arithmetic lives in this hook, not in the component.

13.5.2 Keyboard Event Handling

The hook exports an onKeyPress callback that REPL.tsx passes to the Ink useInput hook. Every keypress that Ink delivers passes through here. The handler dispatches based on the key:

Arrow keys (single-line aware) move the cursor one character in the indicated direction, respecting line boundaries. Arrow Up and Arrow Down in multi-line content move the cursor to the same horizontal offset on the adjacent line rather than jumping to the previous history entry — the distinction between "move cursor up within current input" and "navigate to previous history entry" is determined by whether the cursor is already on the first or last line.

Ctrl+A and Ctrl+E are the Readline shortcuts for beginning-of-line and end-of-line, respectively. They scan through value to find the appropriate newline boundary and set cursorPosition accordingly.

Ctrl+K deletes from the cursor position to the end of the current line and stores the deleted text in yankBuffer. This is the emacs-style "kill to end of line" operation. Ctrl+Y pastes yankBuffer back at the cursor position. These two operations together allow cutting and pasting within the input without leaving the keyboard.

Up and Down outside the multi-line navigation case — that is, when the cursor is already at the top or bottom line — navigate command history. The history array stores every prompt that was submitted in the current session. Pressing Up replaces the current input with the previous entry, saving the current draft if the history index transitions from -1 (current draft) to 0 (most recent history).

Enter without a modifier submits the current input. The hook clears value, resets cursorPosition to 0, appends the submitted text to history, and resets historyIndex to -1. Shift+Enter inserts a literal newline at the cursor position without submitting.

13.5.3 IME Composition Handling

The isComposing flag handles CJK input (Chinese, Japanese, Korean) through input method editors. An IME works by presenting a pre-composition area where the user types phonetic input before committing to a final character. During composition, individual keystrokes should not be treated as direct input; they are part of the phonetic transcription process.

When the Ink input layer signals compositionstart, the hook sets isComposing = true, which causes the key handler to suppress its normal behavior. When compositionend fires, isComposing returns to false and the final composed text is inserted at the cursor as a single operation. Without this guard, CJK users would see their phonetic keystrokes being inserted into the input as raw ASCII before the IME can convert them — a broken experience that is easy to implement correctly and easy to get wrong.


13.6 useTypeahead.tsx — Completion Candidate Pipeline

src/hooks/useTypeahead.tsx

Typeahead completion in Claude Code operates in one of two modes depending on the current input prefix. Slash-prefixed input invokes command completion; @-prefixed input invokes file path completion. Despite their different data sources, both modes produce the same return shape, which is what allows REPL.tsx to render them with a single completion overlay component.

13.6.1 Command Completion Mode

When the input begins with /, the hook queries the command registry for all available commands. It passes the text after the slash through FuzzyPicker's matching algorithm (Chapter 12) against the command names and their aliases. The result is a ranked list of CompletionItem objects sorted by match score, with the highest-scoring item first.

The filtering is incremental: as the user types more characters after /, the candidate list narrows. The FuzzyPicker algorithm assigns higher scores to prefix matches than to interior matches, so /com ranks /compact above /decompose even though both contain the string com.

For commands that have defined argument completions — for example, /model can complete to specific model identifiers — the hook performs a two-level completion. After the command name is unambiguously identified (the input has moved past a space character), the completion switches to argument mode and queries the command's own getCompletions(partialArg) method if it exists.

13.6.2 File Path Completion Mode

When the input contains an @ character, the text following it is treated as a file path prefix. The hook extracts the partial path and calls the filesystem API (readdir on the most specific directory component of the partial path) to get a list of actual files and directories.

The result is filtered in two passes. The first pass removes hidden files (those beginning with .) unless the partial path itself begins with ., in which case hidden files are included because the user is explicitly targeting them. The second pass removes paths that appear in the repository's .gitignore, using the same ignore-rule parser that the tool system uses for file read operations. This prevents the completion list from being polluted with node_modules/ entries and build artifacts.

The filtered candidates are sorted by a combination of recency (recently @-referenced files are ranked higher) and path depth (shallower paths rank above deeper ones when recency is equal).

13.6.3 Common Return Shape

Both modes produce the same hook return value, which is the contract that REPL.tsx and PromptInput depend on:

typescript
// useTypeahead return shape
type TypeaheadResult = {
  items: CompletionItem[]   // ordered list of completion candidates
  selectedIndex: number     // index of the currently highlighted item
  isVisible: boolean        // whether the completion panel should render
  accept: () => void        // apply the selected item to the input
  dismiss: () => void       // close the panel without applying
}

isVisible is false when items is empty, when the input does not begin with / or contain @, or when the user has explicitly dismissed the panel with Escape. The component renders nothing when isVisible is false, so the hook correctly suppresses the overlay when there is nothing useful to show.

accept applies the selected completion to the current input value. For command completion, it replaces the partial command name with the full command name and appends a space, ready for argument input. For file completion, it replaces the partial path after @ with the full selected path.


13.7 useReplBridge.tsx — Remote Session Synchronization

src/hooks/useReplBridge.tsx

Not all consumers of Claude Code are interactive terminal sessions. Some are programmatic callers: mobile clients, web front-ends, CI integrations, and test harnesses. These callers often need to interact with a running session without being the primary terminal operator. useReplBridge is the hook that makes a running REPL session visible and controllable from these remote consumers.

13.7.1 What It Synchronizes

The hook monitors the REPL's application state and sends incremental updates to any connected clients through the src/bridge/ module. The state that is synchronized includes the current message list, the current input value, the active tool permissions pending response, and the overall session status (idle, querying, waiting for permission).

The synchronization is incremental: the hook does not send the full message list on every state change. It maintains a watermark of what has been sent and sends only the delta — messages added since the last sync, and mutations to existing messages (such as an AssistantMessage whose streaming content grew). This keeps the bridge traffic proportional to the rate of change, not the total session size.

13.7.2 Bidirectional Flow

The bridge is bidirectional. In addition to pushing state to remote clients, the hook listens for commands arriving from them. A remote client can submit a prompt, accept a pending permission dialog, or trigger a slash command. When such an inbound action arrives, the hook injects it into the local state as if the local user had performed it: submitted text is enqueued via useCommandQueue's enqueueCommand, and permission responses are resolved through the same deferred-promise mechanism that the local interactive permission dialog uses.

This bidirectionality is what makes the headless SDK model work. A caller that uses the QueryEngine directly (Chapter 9) bypasses the REPL entirely. But a caller that wants to drive an existing interactive session — observe what is happening and inject commands — uses the bridge. The two integration models serve different needs: the SDK is for building autonomous agents; the bridge is for building supervisory tools that augment a human-driven session.

13.7.3 AppState Integration

The hook subscribes to the AppState module, which is a singleton that tracks session-global state outside of any React component. This is necessary because the bridge needs to observe state that spans multiple component instances — for example, the complete conversation history, which REPL.tsx owns but which the bridge must relay to remote clients. The AppState subscription gives the bridge a stable reference that does not change when REPL.tsx re-renders.


13.8 toolPermission/ — Strategy Implementations

src/hooks/toolPermission/

The three-way strategy selection in useCanUseTool delegates to three separate modules in this subdirectory. Each module implements the same logical interface — "given a tool and its input, produce a PermissionDecision" — but does so through a different mechanism suited to its execution context.

13.8.1 interactivePermissions.tsx — Dialog-Based Resolution

This is the strategy for the common case: a human is present at the terminal, the session is interactive, and the tool needs a decision.

The module's core is a React component (PermissionDialogHost) that renders the permission dialog described in Chapter 12. But rendering a UI from a hook that is awaiting a promise requires a coordination mechanism. The module uses a deferred promise pattern: when the strategy's request function is called, it creates a Promise and immediately stores its resolve callback in a useRef. The request function then returns the promise to its caller (the agentic loop), which awaits it and suspends execution. Meanwhile, the stored resolve callback is the function that the user's keypress will eventually call.

typescript
// Deferred promise pattern in interactivePermissions.tsx (conceptual)
const pendingResolve = useRef<((decision: PermissionDecision) => void) | null>(null)

// Called by useCanUseTool when a tool needs permission
async function request(tool, input): Promise<PermissionDecision> {
  return new Promise(resolve => {
    pendingResolve.current = resolve
    setDialogVisible(true)
    setDialogContent({ tool, input })
  })
}

// Called by PermissionDialogHost when user presses a key
function handleUserDecision(decision: PermissionDecision) {
  setDialogVisible(false)
  pendingResolve.current?.(decision)
  pendingResolve.current = null
}

The deferred promise is the correct abstraction here because the user's response time is unbounded — they might think for 30 seconds before deciding. The await in the agentic loop holds the tool execution suspended for that entire duration without polling, without timeouts, and without any additional state machinery.

13.8.2 coordinatorPermissions.ts — Leader Proxy

In a multi-agent topology where the current agent is the coordinator (the top-level agent that dispatches sub-tasks), permission decisions must still ultimately reach a human. But the coordinator may be running in a context where the terminal is owned by a different process or is shared with sub-agents.

The coordinator strategy serializes the permission request and forwards it to the human leader (the process that launched the overall session). The forwarding mechanism uses the same IPC channel that the coordinator uses for all inter-agent communication. The channel carries a PermissionRequest message, the leader's interactive strategy handles it as if it were local, and the response travels back through IPC to the coordinator.

The coordinator caches approved decisions: if the leader approved BashTool with a specific command pattern once during the session, subsequent requests matching the same pattern are resolved locally from the cache without another round-trip through IPC. This is the same optimization that settings-allow rules provide in the local case — it prevents the user from being asked the same question repeatedly as the coordinator's sub-tasks make similar tool calls.

13.8.3 swarmPermissions.ts — Worker IPC Proxy

The swarm worker strategy is structurally similar to the coordinator strategy but is inverted: a worker agent (running inside a spawned process or thread) cannot make permission decisions at all. It must forward every ask result to its coordinator, which in turn may forward to the leader or resolve from its own cache.

The IPC tunneling in this module uses a message queue rather than a direct RPC call. The worker enqueues a PermissionRequest on the coordinator's input channel, then suspends the tool call execution using the same deferred promise pattern from interactivePermissions.tsx. When the coordinator's response arrives on the worker's reply channel, the deferred promise is resolved and the tool call proceeds.

The worker-proxy decision reason is recorded for all decisions resolved through this path. A caller inspecting the session's permission audit log can tell which decisions were made by a human interactively and which were handled by the coordinator acting on delegated authority.

The architecture ensures that no matter how many agents are running in parallel, human oversight remains centralized. The human operator at the terminal sees permission requests from all agents (unless the coordinator's cache absorbs them) and has a single point of control over what the entire swarm is permitted to do.


13.9 The Hooks as a Coherent System

Stepping back, the seven major hooks in this chapter do not operate in isolation. They form a network of dependencies and interactions that collectively constitute the non-rendering logic of the REPL.

useLogMessages and useCommandQueue both write to the message list, but through different paths. useLogMessages appends streaming content from the QueryEngine; useCommandQueue appends the React elements that LocalJSXCommand commands produce. REPL.tsx merges these two streams into the single array that MessageList consumes.

useTextInput and useTypeahead are tightly coupled in the opposite direction: useTextInput owns the value, and useTypeahead reads that value to compute completions. When useTypeahead produces an accept callback, calling it writes back to useTextInput's state, completing the cycle.

useCanUseTool depends on interactivePermissions.tsx, which in turn requires React state for the dialog visibility and content. This means the permission decision mechanism is itself a React concern, not just a plain async function. The hook structure is what makes it possible to have an async operation (waiting for a user keypress) integrated into the React component lifecycle without resorting to global mutable state.

useReplBridge is a consumer of the entire system's output: it reads from REPL.tsx's rendered state and relays it outward. It is also an input channel: commands arriving from the bridge are injected into useCommandQueue. The bridge is the outermost layer of the transport tier, wrapping the other hooks the way a protocol wrapper wraps its payload.

Reading this dependency graph is reading the data flow of the entire interactive session. Every token the model produces enters through useLogMessages. Every character the user types enters through useTextInput. Every tool the model wants to use passes through useCanUseTool. Every slash command the user issues passes through useCommandQueue. And everything that happens is mirrored outward through useReplBridge to whatever external consumers are watching.


Key Takeaways

The src/hooks/ layer exists to solve three problems simultaneously: isolating distinct concerns out of REPL.tsx, bridging non-React systems into the React state model, and enabling reuse across different session topologies. Every hook in the layer can be understood as an answer to one of these three problems.

useCanUseTool is architecturally distinctive because it is a compiled file. Readers opening it will see React Compiler cache infrastructure (_c() calls) that obscures the actual logic. The strategy pattern inside it — coordinator, swarm worker, interactive — reflects the three distinct execution contexts that Claude Code supports, and the toolPermission/ subdirectory contains one implementation module for each strategy.

useLogMessages makes streaming token output practical through batching. The key insight is that text delta events are qualitatively different from structural events (tool calls, results, errors): text deltas are high-frequency and individually insignificant; structural events are low-frequency and individually meaningful. The batching rule treats them differently for exactly this reason.

useCommandQueue provides a serialization guarantee that prevents concurrent command execution from producing undefined interactions. The queue is an instance of a broader principle in the Claude Code codebase: when two asynchronous operations both write to shared state, they should be serialized explicitly rather than left to race.

useTextInput owns substantially more state than a simple value: string. The separation of cursor position, selection range, history index, and IME composition state from the display component is what makes the PromptInput component itself clean: it renders, it does not compute.

useTypeahead abstracts two structurally different data sources — the in-memory command registry and the filesystem — behind a single return shape. This abstraction is what allows REPL.tsx to render both completion modes with one component.

useReplBridge extends the REPL's reach to headless and remote consumers without changing the internal architecture. The bridge pattern — synchronize outward, inject inward — is the correct design for an integration point that must not couple the core system to any particular external consumer.

The toolPermission/ modules all use the deferred promise pattern to integrate asynchronous human decisions into a linear await chain. This pattern recurs throughout the codebase wherever a system must pause execution and wait for input that may arrive on a human timescale.

Built for learners who want to read Claude Code like a real system.