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:
+174
-416
@@ -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":"…"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user