How Nodeterm Maintains the Waiting State for Codex's `request_user_input`
TLDR: Nodeterm keeps a Codex node in the waiting state across turn boundaries by special-casing PreToolUse and PostToolUse events for the request_user_input tool in normalize.ts, then preserving the hold in the agent-status-mirror reducer so the UI continues to show an awaiting-input badge.
When a Codex session invokes the request_user_input tool (the "ask-the-user" operation), the question stays open until the human responds. If Nodeterm treated that as a normal tool call, the node would flip to done the moment the turn finished — losing the visual waiting state. To prevent that, the open-source project eneskirca/nodeterm implements a two-phase mechanism across its normalization and state-reduction layers. Here's exactly how it works, straight from the source.
Where the Waiting State Is Enforced
The logic lives in two complementary files, each responsible for a different stage of the event pipeline:
src/shared/agents/normalize.ts— converts raw Codex hook events into normalized internal events.src/core/agent-status-mirror.ts— merges those normalized events into the node's persistent state.
Both files work together to guarantee that a node stays in the waiting state for as long as the user's answer is pending.
Normalizing Codex Events for request_user_input
The normalizeCodex function in src/shared/agents/normalize.ts inspects every incoming hook event and applies special handling when the tool is request_user_input. The critical logic appears around lines 311–318 of that file.
The behavior is deliberately asymmetric between the two event types:
-
PreToolUseevents — when the tool is about to execute, Nodeterm sets the agent's state towaiting, adds anawaitingInputflag, and stores the question text in aquestionfield. This tells the UI to render the node as "waiting for input" rather than "processing." -
PostToolUseevents — the code intentionally does nothing. This is the single most important detail, because Codex's turn ends with the question still unanswered. If thePostToolUsehandler cleared the waiting flag, the node would transition todoneprematurely. By returningnulland doing nothing, the waiting state survives the turn boundary.
Here's the actual normalization logic:
// 1. Normalization – keep waiting for the question
if (p.tool_name === 'request_user_input' && (ev === 'PreToolUse' || ev === 'PostToolUse')) {
// PreToolUse → mark waiting + store the question
if (ev === 'PreToolUse') {
return {
state: 'waiting',
awaitingInput: true,
question: p.args?.question ?? '',
};
}
// PostToolUse → do nothing (preserve waiting state)
return null;
}
The unit tests in src/shared/agents/normalize.test.ts (lines 372–398) explicitly confirm that both event types are handled this way, so regressions in this behavior get caught automatically.
Preserving the Hold in the State Reducer
Once normalized, the events flow into the reduceEntry function in src/core/agent-status-mirror.ts. This reducer merges events into the node's persistent state, and it's the second line of defense for the waiting state.
The reducer checks whether the entry's hold is an request_user_input hold with awaitingInput set to true. If so, it forces the state back to waiting, re-applies the awaitingInput flag, and carries the stored question text forward — even if other events in the same batch would otherwise transition the node to done.
// 2. State reduction – preserve the waiting hold across turn end
function reduceEntry(entry: AgentEntry) {
// If we have an unanswered request_user_input hold, keep it
if (entry.hold?.tool_name === 'request_user_input' && entry.hold.awaitingInput) {
entry.state = 'waiting';
entry.awaitingInput = true;
entry.question = entry.hold.question;
}
// Normal transition handling …
return entry;
}
This reducer behavior is exercised in src/core/agent-status-mirror.test.ts under the sections titled "codex request_user_input hold (awaitingInput)" and "the request_user_input hold clears it — it commits waiting".
Ending the Waiting State
The waiting state doesn't last forever. Once the user supplies their answer through a separate UserPromptSubmit event, the reducer clears the hold. At that point the awaitingInput flag is removed, and the node follows the normal transition — likely moving to done or whatever state is appropriate for the next step in the agent's workflow.
This means the lifetime of the waiting state is precisely bound:
Codex sends PreToolUse(request_user_input)
→ node enters `waiting` + awaitingInput=true + question stored
Codex sends PostToolUse(request_user_input)
→ normalize returns null; reducer preserves hold; node stays `waiting`
User submits a response (UserPromptSubmit)
→ hold is cleared; awaitingInput removed; node transitions normally
The UI Side: Visualizing the Waiting State
The awaitingInput flag and question field don't just serve internal bookkeeping. The UI layer reads those fields to render a visible badge on the node, so users always know a question is pending.
// 3. UI component – render a waiting badge when awaiting input
function WaitingBadge({ node }: { node: NodeState }) {
if (node.state === 'waiting' && node.awaitingInput) {
return <Badge title={node.question}>Waiting for input…</Badge>;
}
return null;
}
This conditional check — node.state === 'waiting' && node.awaitingInput — is what makes the badge appear only for genuinely unanswered questions, not for other arbitrary waiting conditions.
Key Files and Their Roles
| File | Purpose |
|---|---|
[src/shared/agents/normalize.ts](https://github.com/eneskirca/nodeterm/blob/main/src/shared/agents/normalize.ts#L311-L318) |
Normalizes Codex hook events; contains the special case for request_user_input that differentiates PreToolUse from PostToolUse behavior. |
[src/core/agent-status-mirror.ts](https://github.com/eneskirca/nodeterm/blob/main/src/core/agent-status-mirror.ts#L370-L410) |
Merges normalized events into the node's persisted state; preserves the waiting hold across turn boundaries. |
[src/shared/agents/normalize.test.ts](https://github.com/eneskirca/nodeterm/blob/main/src/shared/agents/normalize.test.ts#L372-L398) |
Unit tests that confirm PreToolUse marks waiting and PostToolUse does not. |
[src/core/agent-status-mirror.test.ts](https://github.com/eneskirca/nodeterm/blob/main/src/core/agent-status-mirror.test.ts#L1796-L2050) |
Tests that ensure the waiting hold persists across normalized event merges. |
Summary
- The waiting state is preserved by not acting on
PostToolUseforrequest_user_input— doing nothing is the deliberate design choice that stops the node from moving todone. PreToolUsemarks the state aswaiting, setsawaitingInput, and records the user's question for UI rendering.- The reducer in
agent-status-mirror.tsre-applies the waiting hold on every merge, acting as a safety net even if other events in the turn would conflict. - Waiting ends only when a separate
UserPromptSubmitevent carries the user's answer, which then clears the hold. - The UI's
WaitingBadgecomponent renders an explicit badge pulled straight from thequestionfield when bothwaitingandawaitingInputare set.
Frequently Asked Questions
Why does Nodeterm ignore the PostToolUse event for request_user_input?
Because Codex's turn ends immediately after a request_user_input PostToolUse fires, and the question is still pending. If the handler cleared the waiting flag there, the node would immediately flip to done, which would mislead the user into thinking the agent had finished. Ignoring the event is the intentional design that lets the waiting state survive the turn boundary.
What exactly does the awaitingInput flag do in the agent state?
awaitingInput is a boolean metadata flag that sits alongside state: 'waiting'. It signals to the UI layer that this particular waiting state derives from an unanswered user question rather than from any other reason (like a tool call in progress). The UI checks it alongside state === 'waiting' before rendering the "Waiting for input…" badge.
How does the waiting state get cleared once the user replies?
A separate UserPromptSubmit event arrives once the user sends their answer. The reducer in src/core/agent-status-mirror.ts recognizes this event, removes the awaitingInput hold, and lets the node transition through its normal lifecycle — effectively moving from waiting to a next step like done or running.
Is the request_user_input behavior specific to Codex, or does it apply to other agents?
It is Codex-specific. The special case lives inside normalizeCodex in src/shared/agents/normalize.ts, not in the generic normalizer. Other agents (like OpenAI's function-calling tool use) have their own normalization paths and do not inherit this special behavior unless they implement similar logic.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →