How Nodeterm Marks Grok's Stop Event with channel_closed or shutdown Reasons

TLDR: Nodeterm's normalizeGrok function in src/shared/agents/normalize.ts interprets a Grok session's Stop hook event and assigns a reason of either channel_closed (communication channel terminated) or shutdown (process terminated), which drives the UI's Running badge cleanup through the agentStatusStore.

The nodeterm repository (owner: eneskirca/nodeterm) is a terminal-based agent runner that normalizes events from multiple AI providers into a single internal representation. When Grok finishes a session, it emits a Stop event — and the way that event is normalized determines whether the UI shows a completion state or silently dismisses the Running badge. This article explains exactly how nodeterm marks Grok's Stop event with channel_closed or shutdown reasons, including the source code that makes it happen.

What Is the Grok Stop Hook Event?

In Grok's SDK, a hook_event_name of "Stop" signals that the agent session has ended. Nodeterm listens for this event to clean up the agent's visual state. The event payload contains a reason field with one of two possible values:

Reason Meaning UI Consequence
channel_closed The Grok session's communication channel was closed (client disconnected, network interruption, etc.). The normalized event is marked as interrupted with reason: "channel_closed".
shutdown The Grok process was shut down by the host (process termination, user quit, etc.). The normalized event is marked with reason: "shutdown"; the turn is treated as finished without a completion alert.

Both reasons clear the working state in the UI, but they differ in how the agent-status store reacts — which is essential for keeping the interface honest about what actually happened.

Where the Reason Is Interpreted: normalizeGrok in normalize.ts

The core logic lives in src/shared/agents/normalize.ts, in a block around line 525 that handles Grok's Stop event. Here is how the flow works for both reasons:

  1. Nodeterm receives a Grok hook event with hook_event_name: "Stop".

  2. The normalizeGrok function inspects the reason field.

  3. If reason === "channel_closed", the normalized event becomes:

    {
      kind: 'state',
      state: 'done',
      reason: 'channel_closed',
      action: 'interrupt'
    }
  4. If reason === "shutdown", the normalized event becomes:

    {
      kind: 'state',
      state: 'done',
      reason: 'shutdown'
    }

The kind is always 'state' and state is always 'done'. The difference lies in the reason field, which downstream consumers use to distinguish an interrupted session from a clean shutdown.

How the Agent-Status Store Reacts to These Reasons

Once normalized, the event feeds the agent-status store located in src/renderer/state/agentStatus.ts. This store maintains a dictionary of agent statuses keyed by a persistKey. When a Stop event arrives with either reason value, the store clears any stale working entry for that node.

The key consumer is the Running badge in src/renderer. The badge renders only while the normalized state is working. When a Stop event with channel_closed or shutdown arrives, the store transitions the node to done, and the badge disappears automatically:

import { useAgentStatus } from '@/renderer/state/agentStatus';

function RunningBadge({ nodeId }) {
  const status = useAgentStatus((s) => s.byId[nodeId]);

  if (!status || status.state !== 'working') return null;

  return <span className="badge">Running…</span>;
}

Because the store removes the working status on these stops, the badge unmounts without the UI triggering a completion alert. For channel_closed the event is additionally marked as interrupt (action: 'interrupt'), which allows UIs to show an interrupted state distinct from a normal completion.

Testing the Behavior

Nodeterm includes unit tests that verify both stop reasons are handled correctly:

  • src/shared/agents/normalize.grok.test.ts — around line 70, this test confirms that a Grok Stop payload with reason: 'channel_closed' produces a normalized event marked as interrupted, and that a reason: 'shutdown' produces the corresponding stop flag.
  • src/renderer/state/agentStatus.test.ts — validates that the store removes working entries after a Stop with either reason, ensuring UI consistency.

Full Code Example: Normalizing a Grok Stop Payload

The following snippet demonstrates how you can call normalizeGrok directly to see what the output looks like:

// Example: Normalizing a Grok Stop payload
import { normalizeGrok } from '@/shared/agents/normalize';

const grokPayload = {
  hook_event_name: 'Stop',
  reason: 'channel_closed',   // or 'shutdown'
  session_id: 'grok-123',
};

const normalized = normalizeGrok(grokPayload);
// normalized.kind === 'state'
// normalized.state === 'done'
// normalized.reason === 'channel_closed'   // or 'shutdown'

And here is how you might consume the normalized event in the store to clean up stale entries:

// Example: Agent-status store cleanup after a Grok Stop
import { agentStatusStore } from '@/renderer/state/agentStatus';

function handleStop(event) {
  if (event.kind === 'state' && event.state === 'done') {
    // Remove any stale working entry for this node
    agentStatusStore.removeWorking(event.persistKey);
  }
}

Key Files for Grok Stop Handling

The following files form the entire pipeline from raw Grok webhook to UI state:

File Purpose
src/shared/agents/normalize.ts Contains the normalizeGrok implementation that interprets the Stop event and extracts the channel_closed / shutdown reasons.
src/shared/agents/normalize.grok.test.ts Unit tests confirming correct handling of both stop reasons.
src/renderer/state/agentStatus.ts The store that holds normalized agent events and clears working entries when a Stop arrives.
src/renderer/state/agentStatus.test.ts Tests for the store's reaction to Grok Stop events.

Summary

  • Nodeterm's normalizeGrok function marks a Grok Stop event with a reason of channel_closed (communication channel closed, treated as an interrupt) or shutdown (process terminated, treated as a clean stop).
  • Both reasons map to a normalized state of 'done', which triggers the agent-status store to remove any pending working entry.
  • The Running badge disappears automatically when either reason arrives because the store clears the working state before the next render.
  • The logic is isolated in src/shared/agents/normalize.ts (around line 535), and is covered by unit tests in normalize.grok.test.ts and agentStatus.test.ts

Frequently Asked Questions

What is the difference between channel_closed and shutdown in nodeterm?

channel_closed indicates that the underlying communication channel with Grok was closed (for example, a client disconnection), and nodeterm treats it as an interrupt. shutdown means the Grok process itself was shut down — a clean stop with no interruption flag. Both produce a done state, but only channel_closed sets action: 'interrupt'.

Where is the normalizeGrok function defined?

In src/shared/agents/normalize.ts, around line 535. It receives the raw Grok payload, checks hook_event_name === 'Stop', reads the reason field, and returns a normalized event with a reason of either channel_closed or shutdown.

How does the UI know a Grok session stopped without showing a completion alert?

The agent-status store in src/renderer/state/agentStatus.ts processes the normalized 'done' event and immediately removes the working entry for that node. Because the store updates before the next render, components like the Running badge see no working state and unmount silently — no alert is shown.

Are these reason flags tested in the nodeterm codebase?

Yes. The test file src/shared/agents/normalize.grok.test.ts covers the normalization logic for both reasons, and src/renderer/state/agentStatus.test.ts confirms the store clears working entries when either stop reason is processed.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →