-
Notifications
You must be signed in to change notification settings - Fork 0
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.
| 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.
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:
-
sessionIdis the join key. For agent nodes it doubles as the node id, which is why the renderer store is keyed by it. -
statusis optional. Codex lifecycle events (SessionStart,SessionEnd,SubagentStart,PreCompact,PostCompact) are recognized but status-less: the session pings, but the badge must not churn. -
kindclassifies the session.sessionis the default in both normalizers;subagentis emitted from theStop/SubagentStopbranch;recurringexists in the vocabulary but no normalizer emits it today. -
toolandtranscriptPathare 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. -
tsis stamped withDate.now()by the normalizer — payload timestamps are not used; the normalizer is the clock. -
Terminal statuses are
waiting,blocked,done;workingis non-terminal.TERMINAL_STATUSESis the single list the notification predicate consults.
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.
| 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) |
— |
| 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) |
— |
- 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. -
waitingis Claude-only today: Claude'sNotificationproduces it; no Codex event in the current mapping does. -
PostToolUseFailureisworkingfor Claude and unrecognized (null) for Codex. - Claude maps
PreCompacttoworking; Codex treatsPreCompactandPostCompactas status-less lifecycle events, and Claude dropsPostCompactentirely. - Subagent classification is confined to the stop branch:
SubagentStop, or asubagent_idpresent onStop, flipskindtosubagent; other events keepkind: 'session'. Notably, the Codex lifecycle branch hardcodeskind: 'session'even forSubagentStart.
shouldNotify(prev, next, { knownSession, windowFocused }) is the one policy function every status consumer shares. It returns true only when all of these hold:
- The session belongs to a live termsprawl node (
knownSession). - The window is not focused.
- The next status is terminal (
waiting,blocked, ordone). - The status actually changed (
prev !== next); a first event that is already terminal also notifies becauseprevisundefined.
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"]
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.
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
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
NORMALIZERStable (claude,codex); the path must match/hook/<agent>. -
HookListener is the injection seam:
new HookServer(listener)hands every acceptedAgentStatusEventto main-process code. The store's header documents the downstream hop — it is "fed by the preload'sagent.onStatuspush channel" — while the exact main-side wiring between the listener and that channel is outside the files read here. -
useAgentStatuses.setis the end of the chain: it records the status and, whenshouldNotifyfires, leavesunread[sessionId] = truefor badge/dot rendering.
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.
-
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'ssetaccepts only concrete statuses, so status-less events cannot become badge state through that API. -
Notification dedupe. Repeats of the same terminal status and all
workingtransitions are suppressed; the unread flag must be cleared explicitly. -
knownSessionis caller-supplied. The renderer hardcodestrue; the gate exists for callers that have not yet correlated an event with a live node.
-
Add a CLI. Write a normalizer with the same signature (
unknown → AgentStatusEvent | null) beside the existing two, then register it in theNORMALIZERSmap inhook-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.
AgentSessionKindalready reservesrecurring; 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
AgentStatusripples throughTERMINAL_STATUSES, both normalizers' mappings, and renderer badge logic — the union is intentionally small. -
Payload fields.
RawHookPayloadhas an index signature, so additional hook fields can be surfaced without loosening types; today onlytool_name,transcript_pathandsubagent_idare 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
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance