Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ dashboard/node_modules/
# Build outputs
dashboard/.next/
dashboard/out/
*.tsbuildinfo
proxy/damascus-proxy
proxy/claude-code-proxy

# Local databases (SQLite flight recorder)
*.db
*.db-wal
*.db-shm

# IDE
.idea/
Expand Down
104 changes: 84 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Claude Code Proxy

A high-performance reverse proxy for intercepting AI coding assistant CLI traffic, with a real-time Next.js dashboard for observability.
A reverse proxy + **session flight recorder** for AI coding assistant CLIs, with a real-time
Next.js dashboard. It intercepts API traffic, ingests Claude Code lifecycle hooks, correlates
the two into durable **coding sessions**, and replays them as normalized timelines.

**Supported CLIs:**
- **Claude Code** → Anthropic API
Expand All @@ -9,14 +11,19 @@ A high-performance reverse proxy for intercepting AI coding assistant CLI traffi
## Architecture

```
Claude Code CLI → Go Proxy (localhost:8080) → api.anthropic.com
Codex CLI → ↓ → api.openai.com
WebSocket broadcast
Next.js Dashboard (localhost:3000)
Claude Code / Codex CLI ──API──▶ Go Proxy (:8080) ──▶ api.anthropic.com / api.openai.com
Claude Code hooks ──POST /events/claude-code──▶ Control plane (:8081)
redact ▶ correlate ▶ SQLite (sessions, events, requests)
WebSocket + REST broadcast
Next.js Dashboard (localhost:3000)
```

The proxy auto-detects the provider based on request headers.
The proxy auto-detects the provider based on request headers. API requests are correlated to
the owning session best-effort (prompt content match, with a most-recent-active fallback) since
the API itself carries no session id. Secrets are redacted before anything is persisted.

## Quick Start

Expand Down Expand Up @@ -67,35 +74,92 @@ codex

You can run both CLIs simultaneously - the proxy auto-detects the provider based on request headers.

### 4. Install Claude Code hooks (enables session timelines)

Without hooks you still get request traffic; with hooks you get full coding **sessions**
(boundaries, prompts, tool calls, file changes) correlated to that traffic.

```bash
./install/install-hooks.sh # merges into ~/.claude/settings.json (backs up first)
./install/install-hooks.sh --print # just print the hook JSON to merge manually
```

The hooks are portable, non-blocking `curl` commands that POST the hook JSON to
`http://localhost:8081/events/claude-code`. Start a new Claude Code session afterwards.

## Configuration

Environment variables (all optional):

| Variable | Default | Purpose |
| --- | --- | --- |
| `CCPROXY_PROXY_ADDR` | `:8080` | reverse proxy listen address |
| `CCPROXY_CONTROL_ADDR` | `:8081` | control plane (WS + hooks + REST) |
| `CCPROXY_DB_PATH` | `~/.claude-code-proxy/data.db` | SQLite database path |
| `CCPROXY_DASHBOARD_TOKEN` | _(unset)_ | bearer token guarding the control plane + WS |
| `NEXT_PUBLIC_CCPROXY_HOST` | `localhost:8081` | dashboard → control plane host |
| `NEXT_PUBLIC_CCPROXY_TOKEN` | _(unset)_ | dashboard bearer token (match the proxy) |

## Control-plane API (`:8081`)

```
GET /health
GET /ws WebSocket stream (sessions + requests)
POST /events/claude-code Claude Code hook ingestion
GET /sessions list sessions (newest first)
GET /sessions/{id} one session
GET /sessions/{id}/timeline normalized timeline (hook events + correlated requests)
```

## Features

- **Coding sessions**: hook-driven session boundaries, prompts, tool calls, file changes
- **Normalized timeline**: per-session merge of lifecycle events and API requests
- **Durable storage**: SQLite (survives restarts), pure-Go driver (no cgo)
- **Secret redaction**: API keys, tokens, AWS keys, PEM blocks scrubbed before persistence
- **Real-time streaming**: Watch SSE events as they arrive
- **Request/Response inspection**: View headers, body, and parsed JSON
- **Token tracking**: Monitor input/output token usage per request
- **Token tracking**: input/output token usage per request and per session
- **Dark theme**: Easy on the eyes during long sessions
- **Auto-reconnect**: Dashboard reconnects automatically if proxy restarts

## Project Structure

```
damascus/
├── proxy/ # Go reverse proxy
│ ├── main.go # Entry point
│ ├── proxy.go # Reverse proxy logic
claude-code-proxy/
├── proxy/ # Go reverse proxy + control plane
│ ├── main.go # Entry point / wiring
│ ├── config.go # Env configuration
│ ├── proxy.go # Reverse proxy, redaction, correlation, persistence
│ ├── sse.go # SSE stream parser
│ ├── store.go # In-memory request storage
│ ├── websocket.go # WebSocket server
│ ├── store.go # In-memory live request cache
│ ├── websocket.go # WebSocket hub (sessions + requests)
│ ├── hooks.go # Hook ingestion + session lifecycle
│ ├── correlation.go # API-request → session correlation
│ ├── redact.go # Secret redaction
│ ├── repository.go # SQLite repository
│ ├── db.go / schema.sql # Database open + schema
│ ├── api.go # /sessions REST endpoints + auth
│ └── types.go # Shared types
├── dashboard/ # Next.js dashboard
├── dashboard/ # Next.js dashboard (sessions-first)
│ └── src/app/
│ ├── page.tsx # Main dashboard
│ ├── page.tsx # Sessions | Requests views
│ ├── components/ # UI components
│ └── hooks/ # WebSocket hook
│ ├── hooks/ # WebSocket hook
│ └── lib/api.ts # Control-plane host/token config
├── install/ # Claude Code hook installer
│ ├── hooks.example.json
│ └── install-hooks.sh
└── README.md
```

## Security Notes

- API keys are automatically redacted in the dashboard
- The proxy only stores requests in memory (clears on restart)
- WebSocket accepts connections from any origin (development mode)
- Secrets (API keys, tokens, AWS keys, PEM private keys, bearer tokens) are redacted from
request/response/hook payloads **before** they are written to disk or broadcast.
- Sensitive headers (`X-Api-Key`, `Authorization`) are redacted.
- Sessions and requests are persisted to SQLite at `CCPROXY_DB_PATH` and survive restarts.
- The WebSocket only accepts same-host (localhost) origins; set `CCPROXY_DASHBOARD_TOKEN` to
require a bearer token on the control plane.
- Redaction is best-effort pattern matching — review before sharing a database file, and treat
the control-plane port as local-only.
49 changes: 30 additions & 19 deletions dashboard/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,54 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

Damascus Dashboard is a Next.js 14 real-time observability UI for monitoring Claude Code API traffic. It connects via WebSocket to a proxy server (running on `ws://localhost:8081/ws`) to display request/response data, streaming SSE events, and token usage statistics.
The Claude Code Proxy Dashboard is a Next.js 14 real-time observability UI for the Claude Code
Proxy "Flight Recorder". It connects via WebSocket to the proxy control plane (default
`ws://localhost:8081/ws`) to display **coding sessions** and the API request/response traffic,
streaming SSE events, and token usage within them. It also reads session timelines over REST.

## Commands

```bash
npm run dev # Start development server
npm run build # Build for production
npm run build # Build for production (also runs typecheck)
npm run lint # Run ESLint
npm run start # Run production build
```

## Architecture

### Data Flow
1. A separate proxy server (not in this repo) captures Claude API traffic and broadcasts events via WebSocket
2. The dashboard connects to `ws://localhost:8081/ws` and receives real-time updates
3. WebSocket messages follow the `WSMessage` protocol defined in `src/app/types.ts`
1. The Go proxy captures Claude/Codex API traffic and ingests Claude Code lifecycle hooks,
persists them to SQLite, and broadcasts events via WebSocket.
2. The dashboard connects to `ws://localhost:8081/ws` and receives real-time updates.
3. Session timelines are fetched on demand via `GET /sessions/{id}/timeline`.
4. WebSocket messages follow the `WSMessage` protocol defined in `src/app/types.ts`.

### WebSocket Message Types
- `INIT`: Initial state with all existing requests
- `REQUEST_START`: New request started
- `RESPONSE_CHUNK`: SSE event chunk from streaming response
- `REQUEST_COMPLETE`: Request finished with final data
- `INIT`: Initial state — all existing requests **and** sessions
- `REQUEST_START` / `RESPONSE_CHUNK` / `REQUEST_COMPLETE`: API request lifecycle
- `SESSION_START`: a new session was observed (from a hook)
- `SESSION_UPDATE`: a session's fields changed (activity, tokens, status)
- `SESSION_EVENT`: a normalized timeline event was appended (detail fetched via REST)

### Control-plane connection
`src/app/lib/api.ts` centralizes the host/token. Override with `NEXT_PUBLIC_CCPROXY_HOST`
(default `localhost:8081`) and `NEXT_PUBLIC_CCPROXY_TOKEN` (when the proxy sets
`CCPROXY_DASHBOARD_TOKEN`).

### Component Structure
- `page.tsx` - Main dashboard layout with master-detail view
- `hooks/useWebSocket.ts` - WebSocket connection with auto-reconnect handling
- `components/StatsBar.tsx` - Connection status and aggregate token counts
- `components/RequestList.tsx` - Scrollable list of captured requests
- `components/RequestDetail.tsx` - Tabbed detail view (Request/Response/Stream)
- `components/StreamViewer.tsx` - Real-time SSE event viewer with auto-scroll
- `components/JsonViewer.tsx` - Collapsible JSON display using `@uiw/react-json-view`
- `page.tsx` — master-detail layout with a **Sessions | Requests** view toggle
- `hooks/useWebSocket.ts` — WebSocket connection (auto-reconnect); tracks requests + sessions
- `components/SessionList.tsx` — sessions (repo, branch, prompt, tokens, duration, status)
- `components/SessionDetail.tsx` — session header + timeline + member requests
- `components/Timeline.tsx` — normalized session timeline (hook events + API requests)
- `components/RequestList.tsx` / `RequestDetail.tsx` — per-request inspection (reused)
- `components/ConversationView.tsx` — reconstructs conversation + tool blocks from SSE
- `components/StreamViewer.tsx` / `JsonViewer.tsx` — raw stream + JSON display

### Key Types (`types.ts`)
- `RequestRecord`: Complete request/response data including headers, body, stream events, and token counts
- `SSEEvent`: Individual Server-Sent Event with event type and data
- `WSMessage`: WebSocket protocol messages
- `Session`, `SessionEvent`, `TimelineEvent`: session-level model
- `RequestRecord` (now carries `sessionId`, `redactionHits`), `SSEEvent`, `WSMessage`

## Tech Stack
- Next.js 14 with App Router
Expand Down
161 changes: 161 additions & 0 deletions dashboard/src/app/components/SessionDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
'use client';

import { useEffect, useState } from 'react';
import { RequestRecord, Session, Signal, TimelineEvent } from '../types';
import { apiGet } from '../lib/api';
import { signalLabel, severityClasses } from '../lib/signals';
import { Timeline } from './Timeline';
import { RequestDetail } from './RequestDetail';

interface SessionDetailProps {
session: Session | null;
requests: RequestRecord[];
}

export function SessionDetail({ session, requests }: SessionDetailProps) {
const [timeline, setTimeline] = useState<TimelineEvent[]>([]);
const [signals, setSignals] = useState<Signal[]>([]);
const [selectedReqId, setSelectedReqId] = useState<string | null>(null);

const sessionId = session?.id;
// Re-fetch the timeline whenever the session changes or registers new activity. The
// SESSION_UPDATE that accompanies every hook/request bumps lastActivityAt, so this stays live.
const activity = session?.lastActivityAt;

useEffect(() => {
setSelectedReqId(null);
}, [sessionId]);

useEffect(() => {
if (!sessionId) {
setTimeline([]);
return;
}
let cancelled = false;
apiGet<TimelineEvent[]>(`/sessions/${encodeURIComponent(sessionId)}/timeline`)
.then((tl) => { if (!cancelled) setTimeline(tl); })
.catch(() => { if (!cancelled) setTimeline([]); });
apiGet<Signal[]>(`/sessions/${encodeURIComponent(sessionId)}/signals`)
.then((s) => { if (!cancelled) setSignals(s); })
.catch(() => { if (!cancelled) setSignals([]); });
return () => { cancelled = true; };
}, [sessionId, activity]);

if (!session) {
return (
<div className="flex items-center justify-center h-full text-zinc-500">
<p>Select a session to view its timeline</p>
</div>
);
}

const memberRequests = requests.filter((r) => r.sessionId === session.id);
const selectedRequest = selectedReqId
? requests.find((r) => r.id === selectedReqId) || null
: null;

if (selectedRequest) {
return (
<div className="h-full flex flex-col">
<button
onClick={() => setSelectedReqId(null)}
className="text-left px-3 py-2 text-xs text-zinc-400 hover:text-zinc-200 border-b border-zinc-800 bg-zinc-900"
>
← Back to session timeline
</button>
<div className="flex-1 overflow-hidden">
<RequestDetail request={selectedRequest} />
</div>
</div>
);
}

const totalTokens = (session.inputTokens || 0) + (session.outputTokens || 0);

return (
<div className="h-full flex flex-col">
{/* Session header */}
<div className="p-3 bg-zinc-900 border-b border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<span
className={`px-2 py-0.5 text-xs font-medium rounded ${
session.status === 'active'
? 'bg-green-900 text-green-300'
: 'bg-zinc-800 text-zinc-400'
}`}
>
{session.status}
</span>
<span className="text-sm font-mono text-zinc-300 truncate">{session.cwd || session.id}</span>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-zinc-500">
<span>ID: {session.id.slice(0, 8)}</span>
{session.model && <span>Model: {session.model}</span>}
{session.packageManager && <span>Pkg: {session.packageManager}</span>}
{session.gitBranch && <span>Branch: {session.gitBranch}</span>}
<span>Requests: {session.requestCount}</span>
{totalTokens > 0 && <span>Tokens: {totalTokens.toLocaleString()}</span>}
{session.permissionMode && <span>Mode: {session.permissionMode}</span>}
</div>
{session.firstPrompt && (
<div className="mt-2 text-sm text-zinc-300 bg-zinc-950 rounded p-2 max-h-24 overflow-y-auto whitespace-pre-wrap">
{session.firstPrompt}
</div>
)}
</div>

{/* Body: signals + timeline + member requests */}
<div className="flex-1 overflow-y-auto">
{signals.length > 0 && (
<div className="border-b border-zinc-800 p-3 space-y-2">
<div className="text-xs font-medium text-zinc-400 uppercase tracking-wide">
Signals ({signals.length})
</div>
{signals.map((s, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<span className={`text-[10px] px-1.5 py-0.5 rounded uppercase shrink-0 mt-0.5 ${severityClasses(s.severity)}`}>
{s.severity}
</span>
<div className="min-w-0">
<div className="text-zinc-200">{signalLabel(s.type)}</div>
<div className="text-xs text-zinc-500">{s.summary}</div>
</div>
</div>
))}
</div>
)}

<div className="px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide">
Timeline
</div>
<Timeline events={timeline} onSelectRequest={setSelectedReqId} />

{memberRequests.length > 0 && (
<div className="border-t border-zinc-800 mt-2">
<div className="px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide">
Requests ({memberRequests.length})
</div>
{memberRequests.map((r) => (
<div
key={r.id}
onClick={() => setSelectedReqId(r.id)}
className="px-3 py-2 border-b border-zinc-800/60 cursor-pointer hover:bg-zinc-800/50 flex items-center justify-between text-xs"
>
<span className="font-mono text-zinc-400 truncate">{r.path}</span>
<div className="flex items-center gap-3 text-zinc-500 shrink-0">
{r.model && <span>{r.model}</span>}
{(r.inputTokens > 0 || r.outputTokens > 0) && (
<span>{(r.inputTokens + r.outputTokens).toLocaleString()} tok</span>
)}
<span className={r.status >= 400 ? 'text-red-400' : 'text-green-400'}>
{r.status || '...'}
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
Loading