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

> Learn how nodeterm's normalizeGrok function marks Grok's Stop event with channel_closed or shutdown reasons, impacting agent status and UI badge cleanup. Understand the process driving the Running badge.

- Repository: [eneskirca/nodeterm](https://github.com/eneskirca/nodeterm)
- Tags: internals
- Published: 2026-08-23

---

**TLDR:** Nodeterm's `normalizeGrok` function in [`src/shared/agents/normalize.ts`](https://github.com/eneskirca/nodeterm/blob/main/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`](https://github.com/eneskirca/nodeterm/blob/main/normalize.ts)

The core logic lives in **[`src/shared/agents/normalize.ts`](https://github.com/eneskirca/nodeterm/blob/main/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:

    ```ts
    {
      kind: 'state',
      state: 'done',
      reason: 'channel_closed',
      action: 'interrupt'
    }
    ```

4. If `reason === "shutdown"`, the normalized event becomes:

    ```ts
    {
      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`](https://github.com/eneskirca/nodeterm/blob/main/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:

```tsx
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`](https://github.com/eneskirca/nodeterm/blob/main/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`](https://github.com/eneskirca/nodeterm/blob/main/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:

```ts
// 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:

```ts
// 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`](https://github.com/eneskirca/nodeterm/blob/main/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`](https://github.com/eneskirca/nodeterm/blob/main/src/shared/agents/normalize.grok.test.ts) | Unit tests confirming correct handling of both stop reasons. |
| [`src/renderer/state/agentStatus.ts`](https://github.com/eneskirca/nodeterm/blob/main/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`](https://github.com/eneskirca/nodeterm/blob/main/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`](https://github.com/eneskirca/nodeterm/blob/main/src/shared/agents/normalize.ts)** (around line 535), and is covered by unit tests in [`normalize.grok.test.ts`](https://github.com/eneskirca/nodeterm/blob/main/normalize.grok.test.ts) and [`agentStatus.test.ts`](https://github.com/eneskirca/nodeterm/blob/main/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`](https://github.com/eneskirca/nodeterm/blob/main/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`](https://github.com/eneskirca/nodeterm/blob/main/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`](https://github.com/eneskirca/nodeterm/blob/main/src/shared/agents/normalize.grok.test.ts) covers the normalization logic for both reasons, and [`src/renderer/state/agentStatus.test.ts`](https://github.com/eneskirca/nodeterm/blob/main/src/renderer/state/agentStatus.test.ts) confirms the store clears `working` entries when either stop reason is processed.