5cb70d702b
- Session sync leaves a transcript alone while hooks feed its session live (active, newest event a hook's, touched within DASHBOARD_SYNC_LIVE_HOOK_MS, default 5 min) and catches it up once quiet; a hook-created session (which importSession skips anyway) is only snapshotted, never parsed. - Hook writer keeps a per-transcript mark (applied after commit): an unchanged TranscriptCache result skips token/compaction/API-error/turn writes, a grown one walks only the entries after the last one handled. - Codex usage: one stamp query per pass instead of one row per journal; between full passes (first + every 5 min) only the newest day folders and journals written in the last hour are statted; fork parents resolved from the table or a full walk. - Cursor/Antigravity polls back off to 60 s after idle passes (server/lib/backoff-poll.js); lane sync runs only while another dashboard shares the data dir (checked every 30 s).
666 lines
36 KiB
Markdown
666 lines
36 KiB
Markdown
# Hook System Integration Guide
|
||
|
||
Comprehensive guide to integrating with Claude Code's hook system for real-time agent monitoring.
|
||
|
||
---
|
||
|
||
## Table of Contents
|
||
|
||
- [Overview](#overview)
|
||
- [Hook Architecture](#hook-architecture)
|
||
- [Hook Installation](#hook-installation)
|
||
- [Hook Types](#hook-types)
|
||
- [Hook Handler Implementation](#hook-handler-implementation)
|
||
- [Event Processing](#event-processing)
|
||
- [Error Handling](#error-handling)
|
||
- [Performance Considerations](#performance-considerations)
|
||
- [Testing Hooks](#testing-hooks)
|
||
- [Troubleshooting](#troubleshooting)
|
||
|
||
---
|
||
|
||
## Overview
|
||
|
||
Claude Code provides a hook system that allows external tools to receive real-time events during agent execution. Agent Dashboard uses these hooks to capture session lifecycle, tool executions, and notifications.
|
||
|
||
> **Privacy (Settings → Database → Privacy, `server/lib/privacy.js`):** a hook whose `cwd` is an ignored folder (`AGENTHUB_IGNORE_CWD`, or under one — path-boundary match) is answered `{ok: true, ignored: true}` and nothing is stored; with **Redact secrets** on (`AGENTHUB_REDACT_SECRETS=1`) the stored `events.data` and `events.summary` have API keys, tokens, bearer headers, private keys and `password=`-style values replaced by `[redacted]` (`trim_event_data` / `redact_text` in `server/db.js`). The hook route still reads the raw payload in memory, and the transcript on disk is untouched. Hooks from another machine can be pushed to `POST /api/hooks/ingest-batch` with a token (see [API.md → Push ingest](./API.md#push-ingest-post-apihooksingest-batch)).
|
||
|
||
> **Cursor (informational):** Live hooks fire from Claude Code. **Cursor** sessions that only exist as JSONL under `~/.claude` (Cursor uses the same paths locally) are still counted — they appear via startup import, continuous project sync, or remote SSH sync, not via hooks.
|
||
|
||
```mermaid
|
||
graph TB
|
||
subgraph "Claude Code Process"
|
||
CLI[Claude CLI]
|
||
Agent[Agent Execution]
|
||
Hooks[Hook System]
|
||
end
|
||
|
||
subgraph "Hook Wiring (~/.claude/settings.json)"
|
||
SessionStart[SessionStart]
|
||
UserPromptSubmit[UserPromptSubmit]
|
||
PreTool[PreToolUse]
|
||
PostTool[PostToolUse]
|
||
Stop[Stop]
|
||
SubagentStop[SubagentStop]
|
||
Notification[Notification]
|
||
SessionEnd[SessionEnd]
|
||
end
|
||
|
||
subgraph "Hook Handler"
|
||
Handler[hook-handler.js]
|
||
end
|
||
|
||
subgraph "Dashboard Server"
|
||
API[Express Server<br/>:4820]
|
||
end
|
||
|
||
Agent --> Hooks
|
||
Hooks -->|stdin JSON| SessionStart
|
||
Hooks -->|stdin JSON| UserPromptSubmit
|
||
Hooks -->|stdin JSON| PreTool
|
||
Hooks -->|stdin JSON| PostTool
|
||
Hooks -->|stdin JSON| Stop
|
||
Hooks -->|stdin JSON| SubagentStop
|
||
Hooks -->|stdin JSON| Notification
|
||
Hooks -->|stdin JSON| SessionEnd
|
||
|
||
SessionStart -->|exec| Handler
|
||
UserPromptSubmit -->|exec| Handler
|
||
PreTool -->|exec| Handler
|
||
PostTool -->|exec| Handler
|
||
Stop -->|exec| Handler
|
||
SubagentStop -->|exec| Handler
|
||
Notification -->|exec| Handler
|
||
SessionEnd -->|exec| Handler
|
||
|
||
Handler -->|HTTP POST| API
|
||
|
||
style Hooks fill:#F59E0B
|
||
style Handler fill:#10B981
|
||
style API fill:#3B82F6
|
||
```
|
||
|
||
---
|
||
|
||
## Hook Architecture
|
||
|
||
### Hook Execution Flow
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant Claude as Claude Code
|
||
participant Handler as hook-handler.js
|
||
participant Server as Dashboard Server
|
||
participant DB as SQLite
|
||
participant WS as WebSocket
|
||
|
||
Claude->>Handler: node hook-handler.js <HookType> (stdin: JSON)
|
||
Handler->>Handler: Wrap stdin as {hook_type, data}
|
||
Handler->>Server: HTTP POST /api/hooks/event (one per dashboard database)
|
||
Handler-->>Claude: exit 0 once the body is sent (never waits for the reply)
|
||
Server->>DB: Insert/Update (one transaction)
|
||
Server->>WS: Broadcast session_updated / agent_updated / new_event …
|
||
|
||
Note over Claude: Continues as soon as<br/>the handler exits
|
||
```
|
||
|
||
> **Security:** the hook handler POSTs to the loopback dashboard (`127.0.0.1:<port>`).
|
||
> The `/api/hooks` ingestion path is **exempt** from the optional `DASHBOARD_TOKEN`
|
||
> gate — it is a local-only write — so hooks keep working without a token even when
|
||
> one is configured for the rest of the API (GHSA-gr74-4xfh-6jw9).
|
||
|
||
### Hook System Characteristics
|
||
|
||
**Design Principles:**
|
||
|
||
```mermaid
|
||
graph TB
|
||
subgraph "Hook System Goals"
|
||
NonBlocking[Non-Blocking<br/>Never block Claude Code]
|
||
FailSafe[Fail-Safe<br/>Errors don't stop execution]
|
||
FastExec[Fast Execution<br/>< 100ms per hook]
|
||
Complete[Complete Data<br/>Capture all events]
|
||
end
|
||
|
||
subgraph "Implementation"
|
||
Timeout[2 s request timeout<br/>2.5 s hard exit]
|
||
ErrorLog[Always exit 0<br/>errors are dropped]
|
||
Async[Fire-and-forget POST<br/>no wait for the reply]
|
||
JSON[JSON Serialization]
|
||
end
|
||
|
||
NonBlocking --> Timeout
|
||
FailSafe --> ErrorLog
|
||
FastExec --> Async
|
||
Complete --> JSON
|
||
|
||
style NonBlocking fill:#10B981
|
||
style FailSafe fill:#10B981
|
||
style FastExec fill:#10B981
|
||
```
|
||
|
||
---
|
||
|
||
## Hook Installation
|
||
|
||
### Installation Script
|
||
|
||
```bash
|
||
# Install hooks
|
||
npm run install-hooks
|
||
```
|
||
|
||
> [!IMPORTANT]
|
||
> **Skip this entirely when the `agenthub` plugin is installed.** The plugin declares
|
||
> the same eight hooks itself (inline in `.claude-plugin/plugin.json`, each
|
||
> running `${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js`). Running both means
|
||
> every event is POSTed twice. Events carry no id, so ingest can only drop a
|
||
> POST whose payload is byte-identical to the session's LATEST event from the last
|
||
> 10 seconds (`findRecentDuplicateEvent`, since v1.8.1) — that stops the doubled
|
||
> feed rows, token/cost figures and subagent cards, but the second hook process
|
||
> still runs on every event, so remove the duplicate registration anyway. `scripts/plugin-bootstrap.js`
|
||
> removes checkout-installed entries on session start (backup:
|
||
> `~/.claude/settings.json.agenthub-bak`) — and since v1.6.17 a settings file that
|
||
> still carries them also blocks the bootstrap's fast path (`hasLegacyHooks`),
|
||
> because the strip runs only in the full pass and a healthy install skipped it
|
||
> every session, keeping the duplicates alive indefinitely. And `install-hooks.js` itself —
|
||
> run by hand or by every checkout server start (`npm run dev`, `npm start`) — adds nothing
|
||
> while settings.json enables `agenthub@…` and removes every `hook-handler.js` entry it finds
|
||
> instead (same backup); it used to put them straight back after each strip, so a dev server
|
||
> beside the plugin kept ~40% of events doubled. Settings reports such hooks as installed
|
||
> (`source: "plugin"`). `/agenthub-doctor` reports any duplicates that remain. See
|
||
> [PLUGINS.md](PLUGINS.md).
|
||
|
||
> [!IMPORTANT]
|
||
> **Hooks are a host-side step.** Claude Code runs on your host, so the hook
|
||
> command must reference a `hook-handler.js` path that exists on the **host**.
|
||
> Run `npm run install-hooks` on the host — never inside a container. When run
|
||
> inside Docker/Podman, the installer **refuses** and exits non-zero (issue
|
||
> #193): a container-internal path written into a bind-mounted `~/.claude` would
|
||
> break every host hook with `MODULE_NOT_FOUND`. The host handler POSTs to
|
||
> `http://localhost:4820`, which a containerized dashboard already publishes.
|
||
> (Escape hatch for running Claude Code *inside* the same container:
|
||
> `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.)
|
||
|
||
For each of the eight hook types it adds one entry to `~/.claude/settings.json` that runs the handler with the type as its argument; the five tool/turn hooks (`PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, `Notification`) carry `"matcher": "*"`. `PreToolUse` and `PostToolUse` also carry `"async": true` (`ASYNC_HOOKS` in `scripts/install-hooks.js`, and the same in `.claude-plugin/plugin.json`): they fire around every tool call, and Claude Code waits for a synchronous hook before going on — each a Node start of 0.1–0.2 s, so ~0.4 s added to every tool call. The handler only reports (it prints nothing and decides nothing), so nothing is lost by not waiting; the rarer hooks stay synchronous so a turn's start and end reach the dashboard in order. Entries that are not AgentHub's are left alone. There is nothing else to install — Claude Code runs `scripts/hook-handler.js` directly.
|
||
|
||
```json
|
||
{
|
||
"hooks": {
|
||
"PreToolUse": [
|
||
{
|
||
"matcher": "*",
|
||
"hooks": [
|
||
{ "type": "command", "command": "node \"/path/to/agenthub/scripts/hook-handler.js\" PreToolUse", "async": true }
|
||
]
|
||
}
|
||
],
|
||
"SessionStart": [
|
||
{
|
||
"hooks": [
|
||
{ "type": "command", "command": "node \"/path/to/agenthub/scripts/hook-handler.js\" SessionStart" }
|
||
]
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
### Verification
|
||
|
||
```bash
|
||
# One line per hook type with an AgentHub entry in settings.json
|
||
# (with the plugin installed the hooks live in its plugin.json, so this prints nothing)
|
||
grep -o 'hook-handler.js[^ ]* [A-Za-z]*' ~/.claude/settings.json
|
||
```
|
||
|
||
The Settings page reports the same (`source: "plugin"` when the plugin supplies them), and `/agenthub-doctor` reports missing or duplicated entries.
|
||
|
||
---
|
||
|
||
## Hook Types
|
||
|
||
### 1. SessionStart
|
||
|
||
Triggered when a Claude Code session starts. The `source` field distinguishes the trigger: `startup` (fresh launch), `resume` (`--resume`/`--continue`), `clear` (`/clear`), and `fork`, and `compact` — which fires **mid-turn** when auto-compaction kicks in while Claude is actively working, not at a fresh prompt.
|
||
|
||
**Payload Example:**
|
||
|
||
```json
|
||
{
|
||
"session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
|
||
"transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
|
||
"cwd": "/home/you/repo",
|
||
"hook_event_name": "SessionStart",
|
||
"source": "startup",
|
||
"model": "claude-opus-5-5"
|
||
}
|
||
```
|
||
|
||
**Purpose:**
|
||
- Create the session and main-agent records on first contact
|
||
- Stamp `awaiting_input_since` (with `awaiting_reason` = `session_start`) so the dashboard shows the row in **Waiting** from the moment the CLI lands at a prompt — **only for `startup`/`resume`/`clear`**. A `compact`-source SessionStart fires mid-turn while Claude is working, so it leaves the awaiting flag untouched: a genuinely-active session stays **Active** (not flipped to Waiting), and a session that compacted while idle keeps its existing Waiting flag and reason
|
||
- Reactivate completed/abandoned sessions on resume
|
||
- Sweep other active sessions whose last activity is older than `DASHBOARD_STALE_MINUTES` (default 180), marking them `abandoned` with their agents `completed` (Remote Data Source sessions, `source` ≠ `local`, are exempt — their status comes from the SSH-mirror reconciliation, not local activity)
|
||
|
||
---
|
||
|
||
### 2. UserPromptSubmit
|
||
|
||
Triggered the moment the user hits enter on a prompt — fires *before* Claude does any work.
|
||
|
||
**Payload Example:**
|
||
|
||
```json
|
||
{
|
||
"session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
|
||
"transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
|
||
"cwd": "/home/you/repo",
|
||
"permission_mode": "default",
|
||
"hook_event_name": "UserPromptSubmit",
|
||
"prompt": "Refactor this function..."
|
||
}
|
||
```
|
||
|
||
**Purpose:**
|
||
- Clear `awaiting_input_since` (and `awaiting_reason` — both reset to NULL together) on the session and main agent
|
||
- Promote the main agent to `working` so the dashboard reflects "Claude is now thinking on this" through the entire response — including text-only replies that emit no `PreToolUse` before `Stop`
|
||
|
||
---
|
||
|
||
### 3. PreToolUse
|
||
|
||
Triggered before a tool executes.
|
||
|
||
**Payload Example:**
|
||
|
||
```json
|
||
{
|
||
"session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
|
||
"transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
|
||
"cwd": "/home/you/repo",
|
||
"permission_mode": "default",
|
||
"hook_event_name": "PreToolUse",
|
||
"tool_name": "Bash",
|
||
"tool_input": { "command": "npm test", "description": "Run the tests" },
|
||
"tool_use_id": "toolu_01AbCdEfGhIjKlMnOpQrStUv"
|
||
}
|
||
```
|
||
|
||
A tool call made inside a subagent also carries `agent_id` and `agent_type`.
|
||
|
||
**Purpose:**
|
||
- Clear `awaiting_input_since` (and `awaiting_reason` — both reset to NULL together; Claude can only call a tool after fresh user input)
|
||
- Set agent to `working`, set `current_tool`
|
||
- Track tool execution start time
|
||
- If tool name is `Agent`, create a subagent record
|
||
|
||
---
|
||
|
||
### 4. PostToolUse
|
||
|
||
Triggered after a tool completes execution.
|
||
|
||
**Payload Example:**
|
||
|
||
```json
|
||
{
|
||
"session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
|
||
"transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
|
||
"cwd": "/home/you/repo",
|
||
"permission_mode": "default",
|
||
"hook_event_name": "PostToolUse",
|
||
"tool_name": "Bash",
|
||
"tool_input": { "command": "npm test", "description": "Run the tests" },
|
||
"tool_response": { "stdout": "…", "stderr": "", "interrupted": false },
|
||
"tool_use_id": "toolu_01AbCdEfGhIjKlMnOpQrStUv",
|
||
"duration_ms": 1234
|
||
}
|
||
```
|
||
|
||
The payload carries no token counts: tokens are read from the transcript (see [Transcript-derived sync](#transcript-derived-sync)).
|
||
|
||
**Purpose:**
|
||
- Clear `awaiting_input_since` (and `awaiting_reason` — both reset to NULL together; covers permission-prompt approval mid-tool)
|
||
- Clear `current_tool` on agent (agent stays `working`)
|
||
- Sync the session's tokens from its transcript, like every event that carries a `transcript_path`
|
||
|
||
**Token and cost flow:** no cost is stored. Tokens land in `token_usage`, one row per session and model bucket, and cost is computed when it is read (`calculateCost`, e.g. `GET /api/pricing/cost`) from those rows and the editable `model_pricing` table.
|
||
|
||
```mermaid
|
||
graph TB
|
||
Hook[Any hook with transcript_path] --> Cache[TranscriptCache<br/>reads only the new bytes]
|
||
Cache --> Usage[token_usage<br/>per session + model bucket]
|
||
Usage --> Read[Cost computed on read<br/>token_usage × model_pricing]
|
||
|
||
style Usage fill:#10B981
|
||
style Read fill:#3B82F6
|
||
```
|
||
|
||
---
|
||
|
||
### 5. Stop
|
||
|
||
Triggered when Claude finishes a turn (NOT when the session is closed).
|
||
|
||
**Payload Example:**
|
||
|
||
```json
|
||
{
|
||
"session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
|
||
"transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
|
||
"cwd": "/home/you/repo",
|
||
"permission_mode": "default",
|
||
"hook_event_name": "Stop",
|
||
"stop_hook_active": false,
|
||
"last_assistant_message": "Done — the tests pass."
|
||
}
|
||
```
|
||
|
||
**Purpose:**
|
||
- Non-error: set main agent to `idle` and stamp `awaiting_input_since` (with `awaiting_reason` = `stop`) — Claude finished its turn, ball is in the user's court. The session shows as **Waiting** until `UserPromptSubmit` / `PreToolUse` fires
|
||
- Error (`stop_reason="error"`): drop `awaiting_input_since` (and `awaiting_reason`, cleared to NULL together), mark the session `error`
|
||
- Background subagents continue running — they complete individually via `SubagentStop`, never via `Stop`
|
||
|
||
> **Note:** `Stop` does **not** fire when the user cancels a turn with `Esc` — interrupts emit no hook at all. The dashboard instead recovers cancelled turns from the transcript (see [User interrupts (Esc)](#user-interrupts-esc--no-hook-fires)).
|
||
|
||
---
|
||
|
||
### 6. SubagentStop
|
||
|
||
Triggered when a sub-agent (explore, task, etc.) completes.
|
||
|
||
**Payload Example:**
|
||
|
||
```json
|
||
{
|
||
"session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
|
||
"transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
|
||
"cwd": "/home/you/repo",
|
||
"permission_mode": "default",
|
||
"hook_event_name": "SubagentStop",
|
||
"agent_id": "a73b0d3904535a49c",
|
||
"agent_type": "Explore",
|
||
"agent_transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01/subagents/agent-a73b0d3904535a49c.jsonl",
|
||
"stop_hook_active": false,
|
||
"last_assistant_message": "Found 3 call sites…"
|
||
}
|
||
```
|
||
|
||
**Purpose:**
|
||
- Match the finishing subagent by description, type, or task and mark it `completed`
|
||
- **Deliberately does NOT clear `awaiting_input_since`** (nor `awaiting_reason`) — a backgrounded subagent finishing tells us nothing about whether the human has responded
|
||
- **Triggers a fire-and-forget JSONL scan** (`scanAndImportSubagents` from `scripts/import-history.js`) after `res.json()` returns. The scan walks the session's `subagents/agent-*.jsonl` files, pairs each assistant `tool_use` block with the next matching user `tool_result` block by `tool_use_id`, and emits per-tool `PreToolUse` + `PostToolUse` events under the subagent's own `agent_id`. Idempotent (`data LIKE '%"tool_use_id":"X"%'` dedup) and merges into a hook-created live row when one matches by `subagent_type + started_at` within 30 s — closes the gap where subagent-internal tool calls would otherwise be invisible to the dashboard
|
||
- **Attributes per-subagent tokens to each subagent's OWN model** (issue #185). Each subagent transcript carries its own `msg.usage` under its own `msg.model`; the scan writes those token buckets to `token_usage` keyed by the real model (e.g. a Haiku QA agent under an Opus orchestrator) so cost is no longer priced at the orchestrator's rate. The subagent's resolved model is also stamped onto its agent row (`metadata.model`). Buckets whose model equals the parent session's model are deliberately **skipped** here — that bucket is owned by the main-transcript writer, and double-writing it would trip `replaceTokenUsage`'s compaction baseline-shift; same-model subagents are reconciled by the authoritative `importSession` / `reconcileTokens` path instead
|
||
- **Rebuilds the nested-subagent hierarchy** (`reconcileSubagentParents`). Subagent rows are inserted flat under the main agent because no single hook event or JSONL file carries the spawner's identity. Each subagent transcript, however, records every child it spawned via the Task tool as `toolUseResult.agentId` (surfaced by `parseSubagentFile` as `spawnedChildren`). The scan inverts these into a child→parent map and repoints `parent_agent_id` (via `setAgentParent`) so a subagent that spawns its own subagents nests under its **true** spawner instead of collapsing to a single level under main; any subagent no other subagent claims stays under main. Idempotent and additive (only rewrites `parent_agent_id`, never inserts/deletes), it also corrects the live PreToolUse-`Agent` parent heuristic's guesses once transcripts land. `scanAndImportSubagents` returns `reparented` alongside `created`; the `SubagentStop` refetch nudge fires when either is non-zero so a pure re-parent still refreshes the tree
|
||
- Imported tool events carry `imported: true, source: "subagent_jsonl"` in their JSON `data` payload so analytics can distinguish backfilled rows from live hook-captured ones if needed
|
||
|
||
---
|
||
|
||
### 7. Notification
|
||
|
||
Triggered when Claude Code sends a system notification.
|
||
|
||
**Payload Example:**
|
||
|
||
```json
|
||
{
|
||
"session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
|
||
"transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
|
||
"cwd": "/home/you/repo",
|
||
"hook_event_name": "Notification",
|
||
"message": "Claude needs your permission to use Bash",
|
||
"notification_type": "permission_prompt"
|
||
}
|
||
```
|
||
|
||
Other `notification_type` values seen: `idle_prompt`, `agent_completed`, `auth_success`.
|
||
|
||
**Purpose:**
|
||
- Log the event for the activity feed
|
||
- If the message matches a permission/input-prompt pattern (`permission`, `waiting for input`, `needs your approval`, `awaiting your response`, …), stamp `awaiting_input_since` (with `awaiting_reason` = `notification`) so the session lands in **Waiting**
|
||
- If the message matches a compaction pattern, tag as a `Compaction` event
|
||
- Trigger a browser notification when the user has notifications enabled — this event and `Stop` are the two "Claude is waiting for YOU" moments, so each has its own switch in Settings → Notifications (`onAwaitingInput` / `onTurnComplete` in `client/src/lib/notifPrefs.ts`) and both default to ON once notifications are enabled
|
||
|
||
---
|
||
|
||
### 8. SessionEnd
|
||
|
||
Triggered when a Claude Code session ends.
|
||
|
||
**Payload Example:**
|
||
|
||
```json
|
||
{
|
||
"session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
|
||
"transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
|
||
"cwd": "/home/you/repo",
|
||
"hook_event_name": "SessionEnd",
|
||
"reason": "prompt_input_exit"
|
||
}
|
||
```
|
||
|
||
`reason` is one of `prompt_input_exit`, `clear`, `resume`, `other`.
|
||
|
||
**Purpose:**
|
||
- Drop `awaiting_input_since` (and `awaiting_reason`, cleared to NULL together) on the session and any agents that still have it
|
||
- Mark all agents and the session as `completed` — **unless the session is in `error` AND that error is still unrecovered at the transcript tail** (`isErrorAtTail`: the latest API error has no successful turn after it), in which case `error` is preserved. A transient error the CLI retried past (successful assistant turns after the last error) finalizes as `completed` instead of freezing in a stale `error`
|
||
- Evict the session's transcript from the shared transcript cache
|
||
|
||
> **Stale-error self-heal.** Separately from `SessionEnd`, the 15 s watchdog now scans `error` sessions (not just `active`) and clears a session back to `active` when its transcript has progressed past the last API error (`isErrorAtTail` is false). Claude auto-retries transient API errors (e.g. "Connection closed mid-response") and keeps working, so an error followed by real turn activity has recovered — recovery previously required a live `UserPromptSubmit`/`PreToolUse` hook, leaving imported or sweep-monitored sessions pinned in `error` indefinitely.
|
||
|
||
---
|
||
|
||
## Hook Handler Implementation
|
||
|
||
### hook-handler.js Architecture
|
||
|
||
```mermaid
|
||
graph TB
|
||
Args[argv: HookType + optional tool] --> Wrap
|
||
Stdin[stdin JSON] --> Wrap[Envelope<br/>hook_type, data, tool?, run_id?]
|
||
Wrap --> Ports[resolveHookIngestPorts<br/>one port per database]
|
||
Ports --> P1[POST /api/hooks/event<br/>dashboard A]
|
||
Ports --> P2[POST /api/hooks/event<br/>dashboard B]
|
||
P1 --> Flushed[Request body sent]
|
||
P2 --> Flushed
|
||
Flushed --> Exit[exit 0]
|
||
Guard[2.5 s safety timer] --> Exit
|
||
|
||
style Exit fill:#10B981
|
||
```
|
||
|
||
### What it does
|
||
|
||
`scripts/hook-handler.js` (about 120 lines) is the whole forwarder:
|
||
|
||
- **Envelope.** It POSTs `{hook_type, data, tool?, run_id?}` to `/api/hooks/event`. `hook_type` is its first argument; `data` is the parsed stdin, or `{raw: "<stdin>"}` when stdin is not JSON. `tool` is `codex` or `antigravity` when those tools' own hook scripts pass it as the second argument (Claude Code passes none). `run_id` is `AGENTHUB_RUN_ID`, set in every pane the dashboard starts.
|
||
- **Fire-and-forget.** It exits once the request body is sent, without waiting for the dashboard's reply, so a busy or wedged dashboard never holds Claude Code at "running hooks".
|
||
- **Never fails.** A refused connection is skipped, a request that takes over 2 s is dropped, and a 2.5 s timer exits regardless. The exit code is always 0.
|
||
|
||
> **Port resolution & fan-out.** The handler resolves its targets at runtime
|
||
> via `server/lib/server-info.js`:
|
||
>
|
||
> 1. If `CLAUDE_DASHBOARD_PORT` is set in the environment, the handler treats
|
||
> it as an explicit operator override and POSTs to that single port —
|
||
> no discovery, no fan-out (useful for tests and container setups).
|
||
> 2. Otherwise it reads `~/.claude/.agenthub.json`, a JSON document
|
||
> that lists every dashboard server currently running on the machine.
|
||
> Each server appends its `{port, pid, startedAt, dataDir}` entry on
|
||
> startup and removes it on a clean shutdown. The handler **prunes any
|
||
> entry whose PID is no longer alive** and POSTs the hook payload to one
|
||
> port per **unique SQLite data directory** (lowest port wins when two
|
||
> instances share `~/.claude/agenthub`). The other instance re-broadcasts
|
||
> the workspaces those hooks moved within `DASHBOARD_LANE_SYNC_MS` (2 s; a server
|
||
> alone on its data dir only looks for such a peer every 30 s), so its
|
||
> browsers see a workspace's status without a refresh.
|
||
> 3. If neither yields a target, the handler falls back to `4820`.
|
||
>
|
||
> Dashboards with **different** databases (e.g. the packaged desktop app using
|
||
> its own Application Support data dir alongside `npm run dev`) still each
|
||
> receive hooks. Dashboards sharing one database never double-ingest events.
|
||
|
||
---
|
||
|
||
## Event Processing
|
||
|
||
### Server-Side Hook Processing
|
||
|
||
`POST /api/hooks/event` (`server/routes/hooks.js`, `ingestHook`):
|
||
|
||
1. **Checks the body** (400 when it is not a hook), maps a Codex or Antigravity payload onto Claude's shape, and answers `{ok: true, ignored: true}` for a hook from an ignored folder and `{ok: true, duplicate: true}` for the same event posted twice within 10 s.
|
||
2. **Reads the transcript first** (incremental `TranscriptCache`), so the write transaction never waits on a large file.
|
||
3. **Runs `processEvent`** — session and agent upserts, the status transitions described under [Hook Types](#hook-types), the event row (redacted when Redact secrets is on) and the transcript-derived sync below — then binds the session to the workspace pane that ran it (`run_id`).
|
||
4. **Broadcasts** the changes over WebSocket: `session_created`, `session_updated`, `agent_created`, `agent_updated`, `new_event`, `workflow_upserted`.
|
||
5. **After the reply**, evaluates event-driven alert rules, and a `SubagentStop` starts the subagent JSONL scan.
|
||
|
||
### Event Processing Pipeline
|
||
|
||
```mermaid
|
||
graph TB
|
||
Hook[Hook Event] --> Validate[Validate Payload]
|
||
Validate --> DB[Database Operations]
|
||
DB --> Session[Update Session]
|
||
DB --> Agent[Update Agent]
|
||
DB --> Tool[Create Tool Record]
|
||
|
||
Session --> Broadcast[Broadcast to WebSocket]
|
||
Agent --> Broadcast
|
||
Tool --> Broadcast
|
||
|
||
Broadcast --> Client1[Client 1]
|
||
Broadcast --> Client2[Client 2]
|
||
Broadcast --> ClientN[Client N]
|
||
|
||
style Validate fill:#3B82F6
|
||
style DB fill:#003B57,color:#fff
|
||
style Broadcast fill:#F59E0B
|
||
```
|
||
|
||
### Transcript-derived sync
|
||
|
||
On every event that carries a `transcript_path`, the shared `TranscriptCache` re-reads the JSONL (incrementally) and the ingestor keeps three session fields in sync with what the user is actually doing in the CLI:
|
||
|
||
- **Tokens / cost** — usage is accumulated per model bucket (compaction-aware baselines).
|
||
- **Model** — the most recent assistant entry's model keeps `sessions.model` current after a `/model` switch.
|
||
- **Name** — the session title is read from the transcript: the `custom-title` line (`/rename`, `claude -n`, picker `Ctrl+R`) always wins, otherwise the auto-generated `ai-title` fills a placeholder/auto name (so a user-chosen name is never clobbered). When neither title exists, the session's **first user prompt** (tool-result, meta/caveat, and slash-command plumbing entries skipped; whitespace-collapsed, 60-char label) fills the placeholder session name plus the main agent's placeholder name and empty task — a later `ai-title` can still replace a descriptor-filled name, and the agent fill passes the in-flight `current_tool` through so it is never wiped mid-turn. `sessions.name` is updated via a no-op-guarded statement and a `session_updated` broadcast fires only on a real change, so the dashboard reflects renames in real time. The 15 s error-detection watchdog runs the same sync for active sessions left idle right after a `/rename`.
|
||
|
||
### User interrupts (Esc) — no hook fires
|
||
|
||
Cancelling a turn with `Esc` fires **no hook at all** (a documented Claude Code limitation — there is no `Stop`, `Notification`, or other event on interrupt). Since `UserPromptSubmit` has already promoted the main agent to `working`, an un-handled cancel would leave the session stuck in `working` indefinitely. The dashboard recovers it from the transcript, via the same 15 s watchdog, two ways:
|
||
|
||
1. **Marker path** — when the cancel happens *after* some output, Claude Code appends a `[Request interrupted by user]` user entry (with an `interruptedMessageId`). `TranscriptCache` reports `pendingInterrupt`, computed from transcript ordering alone: the latest interrupt timestamp vs the latest real turn activity, both on Claude Code's clock. (It is **not** compared against the session's last hook event — those clocks differ, and for a sub-second cancel the `UserPromptSubmit` event is recorded *after* the transcript interrupt, the precise case that used to stay stuck.) The session moves to **Waiting** within ~15 s.
|
||
2. **Idle-working timeout** — when Esc is pressed *before any output*, Claude Code writes **no marker**; the only evidence is silence. When the main agent has been `working` with `current_tool` null and **neither a hook event nor the transcript mtime** has advanced for `DASHBOARD_WORKING_IDLE_SECONDS` (default `120`), the turn is treated as dead. A streaming/long-output turn (transcript still growing) and an in-flight tool call are exempt by those guards; a rare false flip self-heals on the next real hook.
|
||
|
||
Both paths land the session in **Waiting** (main agent → `waiting`, `awaiting_input_since` stamped with `awaiting_reason` = `interrupted` — identical to a non-error `Stop` aside from the reason) and log an `Interrupted` event. A resume (new prompt in the transcript) clears `pendingInterrupt` and the fresh hook keeps the session non-stale.
|
||
|
||
### Missed SessionEnd (dashboard down) — liveness reap
|
||
|
||
`SessionEnd` is the only signal that a session closed, and hooks are fire-and-forget: if the dashboard was **not running** when the user quit (Ctrl+C, terminal closed), the POST fails silently and the event is lost forever — the session previously sat in **Waiting** until the stale sweep (3 h by default). The same 15 s watchdog closes the gap with a **process-liveness probe** (`server/lib/session-liveness.js`): it enumerates running `claude` CLI processes and their working directories (`ps` + `lsof` on macOS, `/proc/<pid>/cwd` on Linux) and completes any `active` session whose `cwd` has no live claude process — the same terminal state a real `SessionEnd` produces, plus a synthetic `SessionEnd` event (`data.source = "liveness-probe"`) on the timeline.
|
||
|
||
Fail-safe guards: the probe reports "no answer" (nothing changes) on Windows, inside containers (host processes are invisible), on `ps`/`lsof` failure, or when disabled via `DASHBOARD_LIVENESS_PROBE=0` (the escape hatch for hooks arriving from another machine); the session must have a `cwd`, and that `cwd` must be **POSIX-absolute** — a household-hook-forwarded session reports the origin machine's own path (e.g. a Windows `D:\Git\ai-deck`) that this host's `/proc`/`lsof` scan can never produce, so the reap skips it rather than falsely completing every remote session (this makes a mixed local + forwarded deployment correct without the blanket `DASHBOARD_LIVENESS_PROBE=0`); **Remote Data Source sessions** (`sessions.source` ≠ `local`) are also skipped outright — their POSIX-absolute `cwd` lives on another machine reached over SSH, so this host's process probe proves nothing about them, and their status is reconciled from the SSH mirror by `remote-sync.js` (the same `source = 'local'` guard also exempts them from the watchdog's error/interrupt scan and both stale sweeps); and — on watchdog ticks only — its transcript must not have been written for at least `DASHBOARD_LIVENESS_IDLE_SECONDS` (default `60`; the last hook write is the fallback clock when no transcript exists on disk) so a mid-turn / just-resumed session never flickers out. The reap runs immediately at startup (rows from a previous run), again ~5 s after startup (rows the startup sync just imported) — both startup passes **skip the idle gate**, so a session quit even seconds before launch clears at once — and on every 15 s watchdog tick (gated) as the safety net. A false completion self-heals — the next hook event reactivates the session.
|
||
|
||
---
|
||
|
||
## Error Handling
|
||
|
||
### Error Handling Strategy
|
||
|
||
The handler has no error path that reaches Claude Code:
|
||
|
||
| What goes wrong | What the handler does |
|
||
|-----------------|-----------------------|
|
||
| stdin is not JSON | Forwards it as `data: {raw: "<stdin>"}` |
|
||
| No dashboard listening (`ECONNREFUSED`) | Skips that target |
|
||
| A dashboard does not take the request within 2 s | Drops it and moves on |
|
||
| Anything hangs | The 2.5 s safety timer exits |
|
||
| The dashboard rejects or fails the event | Not seen — the handler never reads the reply |
|
||
|
||
It always exits 0, so Claude Code never shows a hook error and never waits on AgentHub. Events sent while no dashboard was running are not replayed; the transcript sync and the [liveness reap](#missed-sessionend-dashboard-down--liveness-reap) bring the session up to date later.
|
||
|
||
---
|
||
|
||
## Performance Considerations
|
||
|
||
### Hook Execution Time
|
||
|
||
Claude Code waits only for the handler process: Node start-up, reading stdin, sending the request. Measured on a 4-core desktop: **60–90 ms per hook**, almost all of it Node start-up. The dashboard's work happens after the handler has exited — ingesting a hook took ~2 ms (p50) against a 1.25 GB database at 10 hooks/s, and stayed under 40 ms (p99) at 100 hooks/s with 10 browsers open.
|
||
|
||
### What keeps it fast
|
||
|
||
- **Fire-and-forget POST** — no wait for the dashboard's reply.
|
||
- **Incremental transcript reads** — `TranscriptCache` reads only the bytes appended since the last hook.
|
||
- **Only new transcript entries are written** — a per-transcript mark (`transcriptMarks` in `server/routes/hooks.js`, set once the hook's transaction commits) skips the token, compaction, API-error and turn-duration writes when the transcript did not change, and walks only the entries after the last one handled when it grew.
|
||
- **One POST per database, not per server** — servers sharing a database get each event once (`resolveHookIngestPorts`).
|
||
- **Heavy reads off the event loop** — aggregates run in a worker (`server/lib/read-pool.js`), so a slow page never delays ingest.
|
||
|
||
---
|
||
|
||
## Testing Hooks
|
||
|
||
### Manual Testing
|
||
|
||
Pipe a payload into the handler. It posts to every running dashboard, so use a session id you will recognise:
|
||
|
||
```bash
|
||
echo '{"session_id":"hook-test-001","cwd":"/tmp","hook_event_name":"SessionStart","source":"startup"}' \
|
||
| node scripts/hook-handler.js SessionStart
|
||
|
||
echo '{"session_id":"hook-test-001","cwd":"/tmp","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls"},"tool_use_id":"toolu_test_1"}' \
|
||
| node scripts/hook-handler.js PreToolUse
|
||
```
|
||
|
||
The handler prints nothing. To see the dashboard's answer, POST the envelope yourself:
|
||
|
||
```bash
|
||
curl -s -X POST http://localhost:4820/api/hooks/event \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"hook_type":"Stop","data":{"session_id":"hook-test-001","cwd":"/tmp","hook_event_name":"Stop"}}'
|
||
```
|
||
|
||
### Integration Testing
|
||
|
||
The server suite drives `POST /api/hooks/event` end to end — for example `server/__tests__/api.test.js` and `server/__tests__/awaiting-subagent-guard.test.js`; Codex and Antigravity hooks have `codex-hook-route.test.js` and `antigravity-hook-route.test.js`. Run them with `npm run test:server`, never a bare `node --test` from a workspace pane (some suites start and kill tmux sessions).
|
||
|
||
---
|
||
|
||
## Troubleshooting
|
||
|
||
### Common Issues
|
||
|
||
| Issue | Symptoms | Solution |
|
||
|-------|----------|----------|
|
||
| Hooks not installed | No data in dashboard | `grep hook-handler.js ~/.claude/settings.json` (or check the plugin is enabled), then `npm run install-hooks` or `/agenthub-doctor` |
|
||
| Hooks installed twice | Every event doubled | Plugin and settings.json both carry them — see [Installation Script](#installation-script) |
|
||
| Dashboard not running | Hooks run, nothing arrives | `agenthub start` (or `npm start` from a checkout); events sent meanwhile are not replayed, the transcript sync fills the session in |
|
||
| Dashboard on another port | Nothing arrives | The handler finds servers through `~/.claude/.agenthub.json`; set `CLAUDE_DASHBOARD_PORT` to force one |
|
||
| Hooks installed from a container | `MODULE_NOT_FOUND` in Claude Code | Install on the host — see the container note under [Installation Script](#installation-script) |
|
||
|
||
### Seeing what a hook sends
|
||
|
||
The handler logs nothing by design. POST the envelope yourself (see [Manual Testing](#manual-testing)) and read the reply.
|
||
|
||
### Health Check
|
||
|
||
```bash
|
||
# Check server is running
|
||
curl http://localhost:4820/api/health
|
||
|
||
# Expected: {"status":"ok","version":"…","timestamp":"…"}
|
||
```
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
The hook system provides:
|
||
|
||
- ✅ **Real-time event capture** - Lifecycle, tools, notifications
|
||
- ✅ **Non-blocking execution** - Never delays Claude Code
|
||
- ✅ **Fail-safe design** - Errors don't stop execution
|
||
- ✅ **Fast processing** - < 100ms per hook
|
||
- ✅ **Complete coverage** - All agent lifecycle events
|
||
- ✅ **Easy installation** - One-command setup
|
||
|
||
For server-side processing, see [server/README.md](../server/README.md).
|