Skip to content

Hook Server & CLI Hook Installers

dazeb edited this page Sep 17, 2026 · 2 revisions

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.

Runtime mechanism

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
Loading

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."

Request-path boundaries

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. The error handler 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.

The installer contract

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.

File responsibilities and collaboration

  • src/core/hook-server.ts — transport, routing, and authentication. Owns the per-boot key and exposes it via secret. It depends on src/core/agent-status.ts only for the NORMALIZERS table and the AgentStatusEvent type; 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, plus claudeSettingsPath(homeDir).
  • src/core/codex-hook-installer.ts — Codex config generation, TOML table surgery, legacy repair, and codexConfigPath(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 / normalizeCodexHook and the AgentStatusEvent contract.

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.

Extension points

  • New agent CLI: add an entry to NORMALIZERS in hook-server.ts (the route regex [a-z-]+ already accepts it, and the comment there nominates gemini/custom handlers) and add a matching installer module following the merge-only + marker pattern from hook-installer.ts.
  • New Claude event: extend the EVENTS tuple and the ClaudeHooksConfig interface together — installClaudeHooks casts through Record<string, unknown>, so a mismatch is a runtime gap rather than a type error at that call site.
  • New Codex event: extend EVENTS only; 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 the handle() 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

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