-
Notifications
You must be signed in to change notification settings - Fork 0
Hook Server & CLI Hook Installers
This page covers the inbound half of agent status reporting: a loopback HTTP endpoint that CLI agents POST lifecycle events into, and the installers that write hook configuration into the CLI's own config files so those POSTs happen at all. The payload→status translation itself (what PreToolUse or Stop means for a badge) lives in src/core/agent-status.ts and on the "Agent Status Model & Hook Normalization" page; this page stops at the point where a normalized AgentStatusEvent is handed to a listener.
There are two moving parts that only make sense together.
Outbound: installers write config into the CLI. At boot the app needs the CLI to call back on every lifecycle event. Claude Code supports type: "url" hooks directly, so the Claude installer writes a URL into ~/.claude/settings.json. Codex does not POST anything itself — it runs command handlers — so the Codex installer writes a curl invocation into ~/.codex/config.toml that relays the event JSON (which Codex passes on stdin) to the same server. Both installers are merge-only and marker-keyed so a later uninstall removes exactly what the app added.
Inbound: HookServer accepts and authenticates the POSTs. HookServer binds an ephemeral port on 127.0.0.1 (server.listen(0, '127.0.0.1')) and generates a fresh 24-byte hex secret as a field initializer — so the secret is per-instance, i.e. per boot. That secret is the contract that ties the two halves together: HookServer.secret is passed to the installers as secretKey, gets embedded in the installed URL as ?key=…, and is re-checked on every request. HookServer.url / baseUrl (identical getters) is the other half of that contract; both are only meaningful after start() resolves, since port is 0 until the listen callback fires.
The request path is deliberately two-phase: respond fast, then interpret.
sequenceDiagram
participant CLI as CLI agent (claude / codex)
participant HS as HookServer (127.0.0.1, ephemeral port)
participant N as NORMALIZERS[agent]
participant L as HookListener (injected)
CLI->>HS: POST /hook/claude?key=<per-boot secret>
Note over HS: method gate (POST only), body accumulated with 256 KiB cap
HS-->>CLI: 200 {"ok":true} — sent before parsing
HS->>HS: route /hook/([a-z-]+)/? against NORMALIZERS
alt unknown route / no normalizer
HS->>HS: return silently
else known agent
HS->>HS: key !== this.key → return silently (fail-closed)
HS->>HS: JSON.parse(body) → return on malformed
HS->>N: normalize(parsed)
N-->>HS: AgentStatusEvent | null
HS->>L: listener(event)
end
Key nodes: the 200 is written before handle() runs, so agent CLIs never block on downstream processing and never see an error from a bad payload. The key check happens after routing but before parsing, and a missing key is rejected exactly like a wrong one (key !== this.key). Every rejection is silent — no event, no log, no non-2xx — which is the "fail-open to the agent, fail-closed on authenticity" split that audit B8/B8.1 introduced. The operational consequence: a keyless legacy config, a rotated secret, a route typo, and malformed JSON all present identically as "badges stopped updating."
| Condition | Server behavior | Agent sees |
|---|---|---|
| Non-POST method |
405 + {ok:false,error:'method not allowed'}
|
error response |
Body > 256 KiB (256 * 1024) |
req.destroy() on the data chunk |
connection reset, no 200 |
Unknown /hook/<agent>, no normalizer |
silent return | 200 {"ok":true} |
Missing or wrong ?key=
|
silent return | 200 {"ok":true} |
| Unparseable JSON | silent return | 200 {"ok":true} |
Normalizer returns null
|
no listener call | 200 {"ok":true} |
Two further constraints worth knowing before you touch this file:
-
start()returns a promise that resolves in the listen callback. Theerrorhandler on the socket deliberately does nothing, so a listen failure leaves the promise pending rather than rejected — the comment says "resolve to a dead server," but the observable behavior is a boot-time await that never settles. -
handle()(and therefore the injected listener) runs synchronously inside the'end'handler with no try/catch. Once the response is flushed a throw from the listener becomes an uncaught exception in the HTTP server callback, so listeners must not throw.
stop() closes the server and nulls the field but does not reset port or clear the secret; a new instance gets a new key, so previously installed URLs are stale until the installers rewrite them.
Both installers obey the same three rules — preserve user config, mark what we added, be removable by marker — but implement them very differently because the host formats differ.
Claude (hook-installer.ts) |
Codex (codex-hook-installer.ts) |
|
|---|---|---|
| Target file | ~/.claude/settings.json |
~/.codex/config.toml |
| Hook flavor |
{ type: 'url', url } — CLI POSTs directly |
{ type: 'command', command: curl … } — CLI pipes stdin into curl |
| Marker | top-level __termsprawlManaged: true
|
__termsprawl = true on both the event table and its handler table |
| Merge unit | append one entry to hooks[Event] array |
append [[hooks.<Event>]] + [[hooks.<Event>.hooks]] table pair |
| Uninstall | pop the last entry per event (ours is appended last) | delete every table block containing the marker |
| Repeat install | appends again — no dedupe | early-returns if a current-format managed block exists |
| Events |
PreToolUse, PostToolUse, Notification, Stop, UserPromptSubmit
|
those plus PermissionRequest, SubagentStop, SessionStart, SessionEnd, SubagentStart, PreCompact, PostCompact
|
Claude. buildClaudeHookConfig(baseUrl, secretKey) returns the same entry object under all five event keys; matcher: '*' plus the URL ${baseUrl}hook/claude?key=…. installClaudeHooks reads the file (a missing or unparseable file becomes {}), clones settings.hooks, appends, stamps the marker, and rewrites the whole file as 2-space JSON with a trailing newline. Two consequences to keep in mind: because there is no marker check on install, calling it twice produces two entries per event while uninstallClaudeHooks removes only one; and because readSettings swallows parse errors, an existing-but-invalid settings.json is replaced wholesale rather than reported.
Codex. The command is assembled by handlerCommand: curl -s -o /dev/null -X POST -H 'Content-Type: application/json' --data-binary @- '<url>?key=…', with timeout = 3 on the handler. The URL is a TOML basic string produced by tomlString (JSON.stringify, a compatible escaping subset) specifically because TOML literal strings cannot contain the single quotes the curl command needs. installCodexHooks is a text-level editor: it trims trailing blank lines, appends a managed comment plus nine lines per event, mkdirSyncs the parent directory, and writes. Each event emits both [[hooks.<Event>]] (with matcher and marker) and the nested [[hooks.<Event>.hooks]] — writing only the nested header would make hooks.<Event> an implicit map and Codex would reject the file with "invalid type: map, expected a sequence". hooksTables() / blockContainsMarker() provide the read side: TABLE_RE finds top-level [[hooks.<Event>… headers, and each line range is tested for the marker so user tables — including user tables for the same events — survive uninstall. The legacy-repair branch exists because v0.22–v0.25 wrote config that killed Codex at startup; it detects the bad shapes (''Content-Type doubled quotes, or a marker not immediately followed by a [[hooks. header), uninstalls, and reinstalls. That detection is textual and format-sensitive: any future change to how the marker line is emitted can make a healthy config look legacy.
-
src/core/hook-server.ts— transport, routing, and authentication. Owns the per-boot key and exposes it viasecret. It depends onsrc/core/agent-status.tsonly for theNORMALIZERStable and theAgentStatusEventtype; it has no opinion about status semantics or persistence. Electron-free. -
src/core/hook-installer.ts— Claude config generation and merge/uninstall against~/.claude/settings.json, plusclaudeSettingsPath(homeDir). -
src/core/codex-hook-installer.ts— Codex config generation, TOML table surgery, legacy repair, andcodexConfigPath(homeDir). -
src/main/agents/hook-installer.ts— a 10-line re-export of the Claude installer from core, keeping the main-process import surface stable while the implementation stays Electron-free so Server Edition can use it too. Whether a parallel shim exists for the Codex installer is not visible in these excerpts. -
src/core/agent-status.ts(imported, documented elsewhere) —normalizeClaudeHook/normalizeCodexHookand theAgentStatusEventcontract.
The wiring order is dictated by the code: start the server, await start(), read url/baseUrl, pass that plus secret to the installers, and pass the real listener into the HookServer constructor. The call sites that do this live in the main-process bootstrap and are not in these excerpts — in particular, whether boot calls uninstall-before-install (which matters, given the repeat-install behaviors above) cannot be confirmed from the files shown.
-
New agent CLI: add an entry to
NORMALIZERSinhook-server.ts(the route regex[a-z-]+already accepts it, and the comment there nominatesgemini/custom handlers) and add a matching installer module following the merge-only + marker pattern fromhook-installer.ts. -
New Claude event: extend the
EVENTStuple and theClaudeHooksConfiginterface together —installClaudeHookscasts throughRecord<string, unknown>, so a mismatch is a runtime gap rather than a type error at that call site. -
New Codex event: extend
EVENTSonly; the emit loop and uninstall are event-agnostic. -
Authenticating at higher assurance: the key travels in the query string over loopback only; anything stronger (header-based auth, body signing) must change
handlerCommand,buildClaudeHookConfig, and thehandle()check as one unit.
Open questions and limits: with no logging anywhere on the reject paths, distinguishing a stale secret from a stale port from a malformed payload requires external instrumentation. Config file writes here are plain read-modify-write with no locking, so concurrent writers (desktop app plus Server Edition) can clobber each other.
Sources: src/core/hook-server.ts, src/core/hook-installer.ts, src/core/codex-hook-installer.ts, src/main/agents/hook-installer.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