Skip to content

Agent Status Model & Hook Normalization

dazeb edited this page Sep 17, 2026 · 1 revision

Agent Status Model & Hook Normalization

Every agent CLI reports activity in its own hook vocabulary. This layer collapses those dialects into one small status model — working, waiting, blocked, done — so badges, unread dots and notification policy never branch on "which CLI sent this". The contract is pure TypeScript with no Electron or filesystem imports, and it is shared across main (hook server), preload and renderer.

Module Map

File Responsibility
src/shared/agent-status.ts The contract: AgentStatus, AgentSessionKind, AgentStatusEvent, the terminal-status list, and the pure shouldNotify predicate. No Electron, no fs.
src/core/agent-status.ts Per-CLI normalization: normalizeClaudeHook / normalizeCodexHook turn raw hook JSON into AgentStatusEvent, or null when unrecognized. Re-exports the shared types for its callers.
src/core/hook-server.ts Loopback HTTP endpoint that routes /hook/<agent> to the registered normalizer, enforces the per-boot secret, and delivers accepted events to a HookListener.
src/renderer/src/state/agents.ts Zustand store holding the latest status plus unread flag per session id; consumes shouldNotify to decide when a transition deserves attention.

Adjacent but unrelated: src/shared/agent-tools.ts defines the tool-operation request/result contract (ToolRequest, ToolResult, CanvasToolRequest, …). That is the agent tool protocol, not the status model.

The Shared Contract

export type AgentStatus = 'working' | 'waiting' | 'blocked' | 'done'
export type AgentSessionKind = 'session' | 'subagent' | 'recurring'

export interface AgentStatusEvent {
  sessionId: string
  status?: AgentStatus   // absent on lifecycle-only events
  kind: AgentSessionKind
  tool?: string
  transcriptPath?: string
  ts: number
}

Key properties:

  • sessionId is the join key. For agent nodes it doubles as the node id, which is why the renderer store is keyed by it.
  • status is optional. Codex lifecycle events (SessionStart, SessionEnd, SubagentStart, PreCompact, PostCompact) are recognized but status-less: the session pings, but the badge must not churn.
  • kind classifies the session. session is the default in both normalizers; subagent is emitted from the Stop/SubagentStop branch; recurring exists in the vocabulary but no normalizer emits it today.
  • tool and transcriptPath are copied through only when they are strings. The transcript path (Claude: *.jsonl) lets main read the agent's own session name so node titles can mirror it.
  • ts is stamped with Date.now() by the normalizer — payload timestamps are not used; the normalizer is the clock.
  • Terminal statuses are waiting, blocked, done; working is non-terminal. TERMINAL_STATUSES is the single list the notification predicate consults.

Hook Normalization

Both normalizers live in src/core/agent-status.ts, share a shape, and never throw: any malformed or unrecognized input returns null. The body must be a non-null object whose hook_event_name and session_id are strings; otherwise null.

Claude (normalizeClaudeHook)

Hook event Status Kind
PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit, PreCompact working session
Notification waiting session
PermissionRequest blocked session
Stop done session
SubagentStop, or Stop with a subagent_id done subagent
anything else null (dropped) —

Codex (normalizeCodexHook)

Hook event Status Kind
SessionStart, SessionEnd, SubagentStart, PreCompact, PostCompact (absent — lifecycle-only) session
PreToolUse, PostToolUse, UserPromptSubmit working session
PermissionRequest blocked session
Stop done session
SubagentStop, or Stop with a subagent_id done subagent
anything else null (dropped) —

Cross-CLI differences

  • Codex reuses Claude's payload field vocabulary (hook_event_name, session_id, transcript_path, tool_name) but has a different event set, so the two normalizers are deliberately separate.
  • waiting is Claude-only today: Claude's Notification produces it; no Codex event in the current mapping does.
  • PostToolUseFailure is working for Claude and unrecognized (null) for Codex.
  • Claude maps PreCompact to working; Codex treats PreCompact and PostCompact as status-less lifecycle events, and Claude drops PostCompact entirely.
  • Subagent classification is confined to the stop branch: SubagentStop, or a subagent_id present on Stop, flips kind to subagent; other events keep kind: 'session'. Notably, the Codex lifecycle branch hardcodes kind: 'session' even for SubagentStart.

Notification Decision

shouldNotify(prev, next, { knownSession, windowFocused }) is the one policy function every status consumer shares. It returns true only when all of these hold:

  1. The session belongs to a live termsprawl node (knownSession).
  2. The window is not focused.
  3. The next status is terminal (waiting, blocked, or done).
  4. The status actually changed (prev !== next); a first event that is already terminal also notifies because prev is undefined.
flowchart TD
    A["status transition: prev to next"] --> B{"knownSession and<br/>window unfocused?"}
    B -->|no| N["no notification<br/>no unread change"]
    B -->|yes| C{"next is waiting, blocked, or done?"}
    C -->|no| N
    C -->|yes| D{"prev different from next?"}
    D -->|no| N
    D -->|yes| Y["notify + mark unread"]
Loading

The renderer store calls this with knownSession: true and windowFocused: document.hasFocus(), and uses the result only to set the unread dot — the store itself emits no OS notification. The predicate lives in shared so the decision stays identical across main, preload and renderer.

End-to-End Call Chain

sequenceDiagram
    autonumber
    participant CLI as Agent CLI (Claude / Codex)
    participant HS as HookServer (main, 127.0.0.1)
    participant NORM as normalizeClaudeHook / normalizeCodexHook
    participant LIST as HookListener
    participant PRE as preload agent.onStatus
    participant STORE as useAgentStatuses (renderer)

    CLI->>HS: POST /hook/agent?key=secret
    HS->>HS: method check · 256 KiB body cap · required ?key check
    HS->>NORM: JSON-parsed body
    NORM-->>HS: AgentStatusEvent or null
    HS->>LIST: listener(event) when non-null
    LIST->>PRE: push (main-process wiring)
    PRE->>STORE: agent.onStatus push channel
    STORE->>STORE: shouldNotify(prev, status, knownSession: true, windowFocused: document.hasFocus())
    STORE->>STORE: byId[sessionId] = status; maybe unread[sessionId] = true
Loading

Key nodes:

  • HookServer listens on an ephemeral loopback port (listen(0, '127.0.0.1')) with a per-boot random secret (randomBytes(24)). The installed hook URLs embed that secret, so the ?key= query parameter is required — a missing key is rejected exactly like a wrong one (fail-closed). Every POST still receives a fast 2xx so the agent CLI never errors.
  • Normalizers are selected by agent id through the NORMALIZERS table (claude, codex); the path must match /hook/<agent>.
  • HookListener is the injection seam: new HookServer(listener) hands every accepted AgentStatusEvent to main-process code. The store's header documents the downstream hop — it is "fed by the preload's agent.onStatus push channel" — while the exact main-side wiring between the listener and that channel is outside the files read here.
  • useAgentStatuses.set is the end of the chain: it records the status and, when shouldNotify fires, leaves unread[sessionId] = true for badge/dot rendering.

Renderer State Shape

src/renderer/src/state/agents.ts defines one Zustand store:

Field Meaning
byId sessionId (== node id for agent nodes) → latest AgentStatus.
unread sessionId → whether a terminal transition happened while the window was blurred.
set(sessionId, status) Computes shouldNotify against the previous value, then writes both maps.
clearUnread(sessionId) Clears the dot without touching the status.
clear(sessionId) Removes both entries (e.g., when the node/session goes away).

unread is only ever set to true by set; it is cleared explicitly via clearUnread or removed wholesale via clear.

Boundary Conditions

  • Fail-open to the CLI, fail-closed to spoofing. Unknown agent ids, a missing or wrong ?key=, malformed JSON, and unrecognized events are all silently dropped while the HTTP layer keeps answering quickly.
  • Body cap. A body larger than 256 KiB destroys the request before parsing.
  • Dead server on port conflict. server.on('error') is swallowed: status events simply never arrive, but the agent CLI is unaffected.
  • Method exclusivity. Non-POST requests get 405; agent ids match [a-z-]+, so path characters and casing matter.
  • Optional status. Lifecycle-only events carry no status; consumers must not treat absence as an error, and the renderer store's set accepts only concrete statuses, so status-less events cannot become badge state through that API.
  • Notification dedupe. Repeats of the same terminal status and all working transitions are suppressed; the unread flag must be cleared explicitly.
  • knownSession is caller-supplied. The renderer hardcodes true; the gate exists for callers that have not yet correlated an event with a live node.

Extension Points

  • Add a CLI. Write a normalizer with the same signature (unknown → AgentStatusEvent | null) beside the existing two, then register it in the NORMALIZERS map in hook-server.ts — the comment there marks this as the intended seam ("add gemini/custom here"). Routing is data-driven off the map, so no other module changes.
  • New session kinds. AgentSessionKind already reserves recurring; a normalizer can emit it without changing the event shape, though badge consumers that switch on kind need the new case.
  • Policy changes. Notification behavior is one pure function (shouldNotify), so widening or narrowing who gets notified is a single edit shared by main, preload and renderer.
  • New statuses. Adding a value to AgentStatus ripples through TERMINAL_STATUSES, both normalizers' mappings, and renderer badge logic — the union is intentionally small.
  • Payload fields. RawHookPayload has an index signature, so additional hook fields can be surfaced without loosening types; today only tool_name, transcript_path and subagent_id are read.

Sources: src/shared/agent-status.ts, src/core/agent-status.ts, src/core/hook-server.ts, src/renderer/src/state/agents.ts, src/renderer/src/state/AGENTS.md, src/shared/agent-tools.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally