feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall

- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
2026-10-08 23:04:57 +07:00
parent a9c71cd905
commit b7f3735b29
147 changed files with 5644 additions and 2561 deletions
+174 -416
View File
@@ -89,26 +89,19 @@ graph TB
```mermaid
sequenceDiagram
participant Claude as Claude Code
participant HookScript as Hook Script<br/>(Python)
participant Handler as hook-handler.js
participant Server as Dashboard Server
participant DB as SQLite
participant WS as WebSocket
Claude->>HookScript: Execute (stdin: JSON)
HookScript->>HookScript: Read stdin
HookScript->>Handler: exec node hook-handler.js
Handler->>Handler: Parse JSON
Handler->>Server: HTTP POST /hooks/{type}
Server->>DB: Insert/Update data
DB-->>Server: Success
Server->>WS: Broadcast event
WS-->>Server: Sent to clients
Server-->>Handler: 200 OK
Handler-->>HookScript: exit 0
HookScript-->>Claude: exit 0 (non-blocking)
Note over Claude: Continues execution<br/>without waiting
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>`).
@@ -130,9 +123,9 @@ graph TB
end
subgraph "Implementation"
Timeout[5s Timeout]
ErrorLog[Silent Error Logging]
Async[Async HTTP POST]
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
@@ -189,79 +182,58 @@ npm run install-hooks
> (Escape hatch for running Claude Code *inside* the same container:
> `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.)
This copies hook scripts from `scripts/hooks/` to `.githooks/`:
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": "*"`. Entries that are not AgentHub's are left alone. There is nothing else to install — Claude Code runs `scripts/hook-handler.js` directly.
```mermaid
graph LR
Source[scripts/hooks/*.py] -->|Copy| Target[.githooks/*.py]
Handler[scripts/hook-handler.js] -->|Reference| Target
Target -->|chmod +x| Executable[Executable Hooks]
style Source fill:#3B82F6
style Executable fill:#10B981
```
### Manual Installation
```bash
#!/bin/bash
# scripts/install-hooks.js
HOOKS_DIR=".githooks"
SOURCE_DIR="scripts/hooks"
# Create hooks directory
mkdir -p "$HOOKS_DIR"
# Copy hook scripts
cp "$SOURCE_DIR/session-start.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/pre-tool-use.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/post-tool-use.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/stop.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/subagent-stop.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/notification.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/session-end.py" "$HOOKS_DIR/"
# Make executable
chmod +x "$HOOKS_DIR"/*.py
echo "Hooks installed in .githooks/"
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "node \"/path/to/agenthub/scripts/hook-handler.js\" PreToolUse" }
]
}
],
"SessionStart": [
{
"hooks": [
{ "type": "command", "command": "node \"/path/to/agenthub/scripts/hook-handler.js\" SessionStart" }
]
}
]
}
}
```
### Verification
```bash
# Check hook files exist
ls -la .githooks/
# Expected output:
# session-start.py
# pre-tool-use.py
# post-tool-use.py
# stop.py
# subagent-stop.py
# notification.py
# session-end.py
# 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 `compact` — which fires **mid-turn** when auto-compaction kicks in while Claude is actively working, not at a fresh prompt.
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
{
"type": "sessionStart",
"sessionId": "sess_abc123",
"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-sonnet-4",
"timestamp": "2024-03-18T12:00:00Z"
"model": "claude-opus-5-5"
}
```
@@ -281,10 +253,12 @@ Triggered the moment the user hits enter on a prompt — fires *before* Claude d
```json
{
"type": "userPromptSubmit",
"sessionId": "sess_abc123",
"prompt": "Refactor this function...",
"timestamp": "2024-03-18T12:00:30Z"
"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..."
}
```
@@ -302,14 +276,19 @@ Triggered before a tool executes.
```json
{
"type": "preToolUse",
"sessionId": "sess_abc123",
"agentId": "agent_main_001",
"toolName": "bash",
"timestamp": "2024-03-18T12:01:00Z"
"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`
@@ -326,40 +305,36 @@ Triggered after a tool completes execution.
```json
{
"type": "postToolUse",
"sessionId": "sess_abc123",
"agentId": "agent_main_001",
"toolName": "bash",
"durationMs": 1234,
"success": true,
"inputTokens": 1500,
"outputTokens": 800,
"timestamp": "2024-03-18T12:01:01.234Z"
"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`)
- Update agent token counts via shared transcript cache
- Calculate and update cost
- Rollup cost to session
- Sync the session's tokens from its transcript, like every event that carries a `transcript_path`
**Cost Calculation Flow:**
**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
PostHook[PostToolUse Hook] --> Tokens{Has Token<br/>Counts?}
Tokens -->|Yes| Pricing[Fetch Pricing Rule]
Tokens -->|No| Skip[Skip Cost Update]
Pricing --> Calculate[Cost = <br/>input/1M * input_price +<br/>output/1M * output_price]
Calculate --> UpdateAgent[Update agent.cost]
UpdateAgent --> Rollup[Rollup to session.total_cost]
Rollup --> Broadcast[Broadcast Updates]
style Calculate fill:#10B981
style Broadcast fill:#F59E0B
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
```
---
@@ -372,10 +347,13 @@ Triggered when Claude finishes a turn (NOT when the session is closed).
```json
{
"type": "stop",
"sessionId": "sess_abc123",
"stop_reason": "end_turn",
"timestamp": "2024-03-18T12:05:00Z"
"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."
}
```
@@ -396,11 +374,16 @@ Triggered when a sub-agent (explore, task, etc.) completes.
```json
{
"type": "subagentStop",
"sessionId": "sess_abc123",
"agentId": "agent_explore_002",
"agentType": "explore",
"timestamp": "2024-03-18T12:03:00Z"
"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…"
}
```
@@ -422,14 +405,17 @@ Triggered when Claude Code sends a system notification.
```json
{
"type": "notification",
"sessionId": "sess_abc123",
"notificationType": "backgroundTaskComplete",
"message": "Explore agent completed successfully",
"timestamp": "2024-03-18T12:03:00Z"
"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**
@@ -446,12 +432,16 @@ Triggered when a Claude Code session ends.
```json
{
"type": "sessionEnd",
"sessionId": "sess_abc123",
"timestamp": "2024-03-18T14:30:00Z"
"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`
@@ -467,120 +457,29 @@ Triggered when a Claude Code session ends.
```mermaid
graph TB
CLI[CLI Args] --> Parse[Parse Hook Type]
Stdin[stdin] --> ReadJSON[Read JSON]
Parse --> HookType{Hook Type?}
ReadJSON --> Payload[Event Payload]
HookType -->|session-start| Endpoint1[POST /hooks/session-start]
HookType -->|pre-tool-use| Endpoint2[POST /hooks/pre-tool-use]
HookType -->|post-tool-use| Endpoint3[POST /hooks/post-tool-use]
HookType -->|stop| Endpoint4[POST /hooks/stop]
HookType -->|subagent-stop| Endpoint5[POST /hooks/subagent-stop]
HookType -->|notification| Endpoint6[POST /hooks/notification]
HookType -->|session-end| Endpoint7[POST /hooks/session-end]
Payload --> Endpoint1
Payload --> Endpoint2
Payload --> Endpoint3
Payload --> Endpoint4
Payload --> Endpoint5
Payload --> Endpoint6
Payload --> Endpoint7
Endpoint1 --> HTTP[HTTP POST]
Endpoint2 --> HTTP
Endpoint3 --> HTTP
Endpoint4 --> HTTP
Endpoint5 --> HTTP
Endpoint6 --> HTTP
Endpoint7 --> HTTP
HTTP --> Response{Success?}
Response -->|Yes| Exit0[exit 0]
Response -->|No| Exit1[exit 1]
style HTTP fill:#10B981
style Exit0 fill:#10B981
style Exit1 fill:#EF4444
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
```
### Implementation
### What it does
```javascript
#!/usr/bin/env node
// scripts/hook-handler.js
`scripts/hook-handler.js` (about 120 lines) is the whole forwarder:
const http = require('http');
const fs = require('fs');
- **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.
const HOOK_TYPE = process.argv[2];
const SERVER_URL = 'http://localhost:4820';
const TIMEOUT = 5000; // 5s timeout
// Read JSON from stdin
let inputData = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => inputData += chunk);
process.stdin.on('end', () => {
try {
const payload = JSON.parse(inputData);
sendToServer(HOOK_TYPE, payload);
} catch (err) {
console.error('[hook-handler] JSON parse error:', err);
process.exit(1);
}
});
function sendToServer(hookType, payload) {
const postData = JSON.stringify(payload);
const options = {
hostname: 'localhost',
port: 4820,
path: `/hooks/${hookType}`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData)
},
timeout: TIMEOUT
};
const req = http.request(options, (res) => {
let responseData = '';
res.on('data', (chunk) => responseData += chunk);
res.on('end', () => {
if (res.statusCode === 200) {
process.exit(0);
} else {
console.error(`[hook-handler] Server error: ${res.statusCode}`);
process.exit(1);
}
});
});
req.on('error', (err) => {
console.error('[hook-handler] Request error:', err);
process.exit(1);
});
req.on('timeout', () => {
console.error('[hook-handler] Request timeout');
req.destroy();
process.exit(1);
});
req.write(postData);
req.end();
}
```
> **Port resolution & fan-out.** The snippet above shows a single fixed `4820`
> for clarity. The real `scripts/hook-handler.js` resolves hook targets at
> runtime via `server/lib/server-info.js`:
> **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 —
@@ -606,38 +505,13 @@ function sendToServer(hookType, payload) {
### Server-Side Hook Processing
```javascript
// server/routes/hooks.js
`POST /api/hooks/event` (`server/routes/hooks.js`, `ingestHook`):
router.post('/session-start', (req, res) => {
try {
const { sessionId, model, agentId, agentType } = req.body;
// Upsert session
let session = stmts.findSession.get(sessionId);
if (!session) {
stmts.createSession.run(sessionId, model);
session = stmts.findSession.get(sessionId);
broadcast({ type: 'session.created', data: session });
}
// Create main agent
if (!stmts.findAgent.get(agentId)) {
stmts.createAgent.run(agentId, sessionId, agentType);
const agent = stmts.findAgent.get(agentId);
broadcast({ type: 'agent.created', data: agent });
}
// Touch session (update updated_at)
stmts.touchSession.run(sessionId);
res.json({ success: true });
} catch (err) {
console.error('session-start error:', err);
res.json({ success: false, error: err.message });
}
});
```
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
@@ -691,64 +565,17 @@ Fail-safe guards: the probe reports "no answer" (nothing changes) on Windows, in
### Error Handling Strategy
```mermaid
graph TB
Error[Error Occurs] --> Type{Error Type?}
Type -->|Network Error| Retry[Retry Once]
Type -->|Timeout| Log1[Log + Exit 1]
Type -->|Parse Error| Log2[Log + Exit 1]
Type -->|Server Error| Log3[Log + Exit 1]
Retry --> Success{Success?}
Success -->|Yes| Exit0[Exit 0]
Success -->|No| Exit1[Exit 1]
Log1 --> Exit1
Log2 --> Exit1
Log3 --> Exit1
style Exit0 fill:#10B981
style Exit1 fill:#EF4444
```
The handler has no error path that reaches Claude Code:
### Hook Script Error Handling
| 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 |
```python
#!/usr/bin/env python3
# .githooks/session-start.py
import sys
import json
import subprocess
import logging
logging.basicConfig(
filename='.githooks/hooks.log',
level=logging.ERROR,
format='%(asctime)s - %(levelname)s - %(message)s'
)
try:
data = json.load(sys.stdin)
result = subprocess.run(
['node', 'scripts/hook-handler.js', 'session-start'],
input=json.dumps(data),
text=True,
timeout=5,
capture_output=True
)
if result.returncode != 0:
logging.error(f'Hook handler failed: {result.stderr}')
except Exception as e:
logging.error(f'Hook error: {str(e)}')
# Always exit 0 to avoid blocking Claude Code
sys.exit(0)
```
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.
---
@@ -756,55 +583,14 @@ sys.exit(0)
### Hook Execution Time
```mermaid
graph TB
subgraph "Hook Execution Breakdown"
Python[Python Script<br/>~10ms]
Handler[Node Handler<br/>~20ms]
HTTP[HTTP POST<br/>~30ms]
DB[Database Write<br/>~5ms]
WS[WebSocket Broadcast<br/>~5ms]
end
Total[Total: ~70ms]
Python --> Handler
Handler --> HTTP
HTTP --> DB
DB --> WS
WS --> Total
style Total fill:#10B981
```
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.
**Performance Targets:**
### What keeps it fast
| Phase | Target | Actual |
|-------|--------|--------|
| Hook script | < 20ms | ~10ms |
| Handler | < 30ms | ~20ms |
| HTTP POST | < 50ms | ~30ms |
| Database | < 10ms | ~5ms |
| **Total** | **< 100ms** | **~70ms** |
### Optimization Techniques
```mermaid
graph TB
subgraph "Optimizations"
Async[Async HTTP<br/>Don't wait for response]
Batch[Batch Updates<br/>Transaction batching]
Index[Database Indexes<br/>Fast lookups]
Pool[Connection Pooling<br/>Reuse connections]
end
Async --> Faster[Faster Hook Execution]
Batch --> Faster
Index --> Faster
Pool --> Faster
style Faster fill:#10B981
```
- **Fire-and-forget POST** — no wait for the dashboard's reply.
- **Incremental transcript reads** — `TranscriptCache` reads only the bytes appended since the last hook.
- **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.
---
@@ -812,49 +598,27 @@ graph TB
### Manual Testing
Pipe a payload into the handler. It posts to every running dashboard, so use a session id you will recognise:
```bash
# Test session-start hook
echo '{"type":"sessionStart","sessionId":"test_001","model":"claude-sonnet-4","agentId":"agent_test","agentType":"general-purpose"}' | \
python3 .githooks/session-start.py
echo '{"session_id":"hook-test-001","cwd":"/tmp","hook_event_name":"SessionStart","source":"startup"}' \
| node scripts/hook-handler.js SessionStart
# Test pre-tool-use hook
echo '{"type":"preToolUse","sessionId":"test_001","agentId":"agent_test","toolName":"bash"}' | \
python3 .githooks/pre-tool-use.py
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
```
# Test post-tool-use hook
echo '{"type":"postToolUse","sessionId":"test_001","agentId":"agent_test","toolName":"bash","durationMs":100,"success":true,"inputTokens":1000,"outputTokens":500}' | \
python3 .githooks/post-tool-use.py
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
```javascript
// server/__tests__/hooks.test.js
import { test } from 'node:test';
import assert from 'node:assert';
test('session-start hook creates session', async () => {
const payload = {
sessionId: 'test_session',
model: 'claude-sonnet-4',
agentId: 'test_agent',
agentType: 'general-purpose'
};
const response = await fetch('http://localhost:4820/hooks/session-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
assert.strictEqual(data.success, true);
// Verify session exists
const session = await fetch('http://localhost:4820/api/sessions/test_session');
assert.strictEqual(session.status, 200);
});
```
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).
---
@@ -864,29 +628,23 @@ test('session-start hook creates session', async () => {
| Issue | Symptoms | Solution |
|-------|----------|----------|
| Hooks not executing | No data in dashboard | Check `.githooks/` exists and scripts are executable |
| Timeout errors | Hooks take >5s | Check server is running, reduce timeout |
| Parse errors | JSON parse failed | Validate hook payload format |
| Permission denied | Hook script won't run | `chmod +x .githooks/*.py` |
| Server connection refused | HTTP POST fails | Start dashboard server (`npm start`) |
| 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) |
### Debug Mode
### Seeing what a hook sends
```bash
# Enable hook logging
export DASHBOARD_DEBUG=1
# Run hook manually with verbose output
python3 -u .githooks/session-start.py < test-payload.json
```
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/sessions
curl http://localhost:4820/api/health
# Expected: {"sessions": [...]}
# Expected: {"status":"ok","version":"…","timestamp":"…"}
```
---