Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ skills-lock.json
videos/
ccxray-init.png
docs/src/
.tmp/
120 changes: 9 additions & 111 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,117 +2,15 @@

Guidance for Codex when working with this repository.

## What is ccxray
## Source of Truth

A transparent HTTP proxy that sits between Codex and the Anthropic API. It records every request/response, serves a real-time Miller-column dashboard at the same port, and supports request interception/editing. Zero config, zero dependencies beyond Node.js.
Use `CLAUDE.md` as the canonical repository instructions file.

## Commands
Codex should read and follow `CLAUDE.md` for:
- project overview
- commands
- architecture notes
- launcher behavior
- data flow and storage details

```bash
ccxray # Proxy + dashboard (set ANTHROPIC_BASE_URL=http://localhost:5577 before running Codex)
ccxray --port 8080 # Custom port
ccxray status # Show hub info and connected clients
npm run dev # Dev mode (auto-restart on server/public changes)
npm test # Run tests
```

No build step. No linting. Restart to apply changes.

> **Note for Codex**: ccxray proxies the Anthropic API. It does not spawn Codex itself. Start ccxray first, then ensure `ANTHROPIC_BASE_URL=http://localhost:5577` is set in the environment where Codex runs.

## Architecture

### Server (`server/`)

| Module | Purpose |
|--------|---------|
| `server/index.js` | Entry point: HTTP server, request routing, startup |
| `server/config.js` | PORT, ANTHROPIC_HOST/PORT/PROTOCOL, LOGS_DIR, MAX_ENTRIES, model context windows |
| `server/pricing.js` | LiteLLM price fetch, 24h cache, fallback rates, cost calculation |
| `server/cost-budget.js` | Cost data orchestration: cache, warm-up, grouping |
| `server/cost-worker.js` | Child process: scans `~/.claude/projects` and `~/.config/claude/projects` JSONL files without blocking event loop |
| `server/store.js` | In-memory state: entries[] (capped at MAX_ENTRIES), sseClients[], sessions, intercept, versionIndex (keyed by `agentKey::coreHash`). Session detection with subagent inference (inflight + temporal heuristic) |
| `server/sse-broadcast.js` | SSE broadcast to dashboard clients, entry summarization |
| `server/helpers.js` | Tokenization, context breakdown, SSE parsing, formatting |
| `server/system-prompt.js` | KNOWN_AGENTS registry, agent type detection, B2 block splitting, unified diff |
| `server/restore.js` | Startup log restoration, lazy-load req/res from disk, delta chain reconstruction |
| `server/forward.js` | HTTP/HTTPS proxy to Anthropic, SSE capture, response logging, proxyRes error handling |
| `server/routes/api.js` | REST endpoints for entries, tokens, system prompt |
| `server/routes/sse.js` | SSE endpoint |
| `server/routes/intercept.js` | Intercept toggle/approve/reject/timeout |
| `server/routes/costs.js` | Cost budget endpoints |
| `server/hub.js` | Multi-project hub: lockfile (`~/.ccxray/hub.json`), discovery (with orphan port probe fallback), client registration, idle shutdown (injectable via setOnShutdown), crash auto-recovery |
| `server/auth.js` | API key auth middleware (enabled via `AUTH_TOKEN` env) |
| `server/storage/` | Storage adapters (local filesystem, S3/R2). `statShared()` for file mtime. `supportsDelta` flag gates delta-write eligibility |

### Client (`public/`)

| File | Purpose |
|------|---------|
| `public/index.html` | Dashboard shell |
| `public/style.css` | Dark theme, Miller column layout |
| `public/app.js` | App initialization |
| `public/miller-columns.js` | Projects → Sessions → Turns → Sections → Timeline → Detail |
| `public/entry-rendering.js` | Turn rendering, session/project tracking |
| `public/messages.js` | Merged steps: thinking + tool groups, timeline detail, minimap rendering + layout |
| `public/cost-budget-ui.js` | Cost analysis page, heatmap, burn rate |
| `public/intercept-ui.js` | Pause/edit/approve/reject requests |
| `public/system-prompt-ui.js` | Multi-agent browsing (3-column Miller), version history, unified diffs |
| `public/keyboard-nav.js` | Arrow keys, Enter, Escape |
| `public/quota-ticker.js` | Topbar quota ticker |

### Hub Mode (multi-project)

Hub mode is activated by `ccxray claude` (spawns Claude Code as a child). When using Codex, run `ccxray` directly and share the hub by pointing all Codex instances at the same `ANTHROPIC_BASE_URL`.

```
ccxray claude (1st) → fork detached hub → connect as client → spawn claude
ccxray claude (2nd) → discover hub via ~/.ccxray/hub.json → connect as client → spawn claude
Hub (detached process)
├── HTTP proxy on :5577
├── Dashboard (same port)
├── Client registry (register/unregister/health)
└── Idle shutdown (5s after last client exits)
```

- Hub lockfile: `~/.ccxray/hub.json` (written after `listen()` succeeds = readiness signal)
- Hub log: `~/.ccxray/hub.log` (stdout/stderr of detached process)
- `--port` opts out of hub mode entirely (independent server)
- Crash recovery: clients monitor hub pid every 5s, auto-fork new hub using port as mutex
- Version check: semver major mismatch → reject, minor → warn, patch → silent

### Data Flow

```
Codex → proxy receives request → detect session (explicit or inferred)
→ [intercept check] → log {id}_req.json → forward to Anthropic
→ capture SSE response → log {id}_res.json → calculate cost
→ broadcast via SSE (includes sessionInferred flag) → dashboard updates
```

Logs stored in `~/.ccxray/logs/` (not package-relative). Respects `CCXRAY_HOME` env var.

### Delta Log Storage

Each `_req.json` normally stores the full `messages` array. For long sessions this wastes 85–90% of disk space (each turn re-stores the entire conversation history). Delta storage writes only new messages and a pointer to the previous turn.

**Format** (delta turn):
```json
{ "model": "...", "max_tokens": 8096, "prevId": "2026-05-01T11-47-17-808", "msgOffset": 18,
"messages": [ /* only messages[18..] */ ], "sysHash": "...", "toolsHash": "..." }
```

**Format** (full / anchor turn):
```json
{ "model": "...", "max_tokens": 8096, "messages": [ /* all */ ], "sysHash": "...", "toolsHash": "..." }
```

Rules:
- Delta only applies to sessions with an explicit `session_id` (main orchestrator turns). Subagents and inferred sessions always write full format.
- First turn of a session = always full (chain anchor).
- Compaction (messages shrinks) = always full (resets chain).
- `supportsDelta: false` on the storage adapter (e.g. S3) disables delta entirely.
- `CCXRAY_DELTA_SNAPSHOT_N=N` forces a full snapshot every N delta writes (default `0` = only session-start anchor). Use `5` for S3-backed setups.

**Read side**: `loadEntryReqRes` detects `prevId`, recursively loads the chain, and splices `prevMessages[0..msgOffset]` + delta messages. Results are cached in memory (per entry). If `prevId` entry has been pruned, gracefully degrades to showing only the delta portion.
Keep this file intentionally small so Codex and Claude do not drift. When repository instructions change, update `CLAUDE.md` first and only adjust this routing note if the routing policy itself changes.
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ No build step. No linting. Restart to apply changes.
| `server/routes/costs.js` | Cost budget endpoints |
| `server/hub.js` | Multi-project hub: lockfile (`~/.ccxray/hub.json`), discovery (with orphan port probe fallback), client registration, idle shutdown (injectable via setOnShutdown), crash auto-recovery |
| `server/auth.js` | API key auth middleware (enabled via `AUTH_TOKEN` env) |
| `server/openai-session.js` | Shared OpenAI/Codex header + session helpers (session id extraction, agent type, turn-metadata sidecar) |
| `server/ws-proxy.js` | OpenAI WebSocket transport proxy for `/v1/responses` and `/v1/realtime` upgrades. Tunables: `CCXRAY_WS_IDLE_TIMEOUT_MS` (default 60s), `CCXRAY_WS_MAX_QUEUE_BYTES` (default 4 MiB; caps client→upstream buffer while upstream is connecting) |
| `server/storage/` | Storage adapters (local filesystem, S3/R2). `statShared()` for file mtime. `supportsDelta` flag gates delta-write eligibility |

### Client (`public/`)
Expand Down Expand Up @@ -84,9 +86,10 @@ ccxray claude (2nd) → discover hub via ~/.ccxray/hub.json → connect as clie

- Launchers are registered in `server/providers.js`. Add future providers there with one entry for command name, display name, upstream family, launch args/env, and install hint; avoid adding new `if provider` branches in `server/index.js`.
- Claude mode sets `ANTHROPIC_BASE_URL=http://localhost:<port>` in the spawned Claude process.
- Codex mode spawns `codex -c 'openai_base_url="http://localhost:<port>/v1"' ...args` and logs raw OpenAI Responses request/response JSON.
- Codex mode spawns `codex -c 'openai_base_url="http://localhost:<port>/v1"' -c 'chatgpt_base_url="http://localhost:<port>/v1"' ...args`, covering both API-key and ChatGPT-auth Codex transports.
- Extra user args pass through unchanged after ccxray's injected launcher config.
- `--no-browser` only suppresses browser auto-open. The dashboard remains available on the proxy port.
- Codex's main session traffic upgrades to a WebSocket on `POST /v1/responses` (with `openai-beta: responses_websockets=*`), not `/v1/realtime`. `/v1/realtime` exists for the older Realtime API but is not what current codex uses for normal `/goal` / chat turns. When ChatGPT auth is active, codex also sends `chatgpt-account-id`, which `getUpstreamForRequestAndHeaders` (see `server/config.js`) uses to route to `CHATGPT_BASE_URL` instead of `OPENAI_BASE_URL`.

### Data Flow

Expand Down
17 changes: 17 additions & 0 deletions server/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,32 @@ function getUpstream(provider) {
function getProviderForRequest(urlPath) {
const pathname = (urlPath || '').split('?')[0];
if (pathname === '/v1/responses' || pathname.startsWith('/v1/responses/')) return 'openai';
if (pathname === '/v1/realtime' || pathname.startsWith('/v1/realtime/')) return 'openai';
if (pathname === '/v1/models' || pathname.startsWith('/v1/models/')) return 'openai';
if (isChatGPTCodexPath(pathname)) return 'openai';
return 'anthropic';
}

function getUpstreamForRequest(urlPath) {
return getUpstream(getProviderForRequest(urlPath));
}

function isChatGPTCodexPath(pathname) {
return pathname === '/v1/api/codex'
|| pathname.startsWith('/v1/api/codex/')
|| pathname === '/v1/codex'
|| pathname.startsWith('/v1/codex/')
|| pathname === '/v1/plugins'
|| pathname.startsWith('/v1/plugins/')
|| pathname === '/v1/connectors'
|| pathname.startsWith('/v1/connectors/');
}

function getUpstreamForRequestAndHeaders(urlPath, headers = {}) {
const pathname = (urlPath || '').split('?')[0];
if (isChatGPTCodexPath(pathname)) {
return UPSTREAMS.openaiChatGPT;
}
const upstream = getUpstreamForRequest(urlPath);
if (upstream.provider === 'openai' && headers['chatgpt-account-id']) {
return UPSTREAMS.openaiChatGPT;
Expand Down
11 changes: 6 additions & 5 deletions server/forward.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const helpers = require('./helpers');
const { broadcast, broadcastSessionStatus, broadcastSessionTitleUpdate } = require('./sse-broadcast');
const { appendSample, collectRatelimitHeaders } = require('./ratelimit-log');
const hub = require('./hub');
const { stripAuthParams } = require('./url-sanitize');

// For title-generator subagent responses, extract the clean title from the
// JSON payload and (when attribution succeeds) stamp it onto the parent
Expand Down Expand Up @@ -332,7 +333,7 @@ function forwardRequest(ctx) {
reqId: id,
});
helpers.printSeparator();
console.log(`\x1b[36m📤 [${ts}] ${ctx.attribPrefix} ${clientReq.method} ${clientReq.url}\x1b[0m`);
console.log(`\x1b[36m📤 [${ts}] ${ctx.attribPrefix} ${clientReq.method} ${stripAuthParams(clientReq.url)}\x1b[0m`);
console.log(helpers.summarizeRequest(parsedBody));
}

Expand All @@ -345,7 +346,7 @@ function forwardRequest(ctx) {
const tunnelAgent = getTunnelAgent(upstream);
const proxyReq = transport.request({
hostname: upstream.host, port: upstream.port,
path: config.joinUpstreamPath(upstream, clientReq.url), method: clientReq.method,
path: config.joinUpstreamPath(upstream, stripAuthParams(clientReq.url)), method: clientReq.method,
headers: { ...fwdHeaders, 'content-length': bodyToSend.length },
...(tunnelAgent ? { agent: tunnelAgent } : {}),
}, (proxyRes) => {
Expand Down Expand Up @@ -581,7 +582,7 @@ function handleSSEResponse(ctx, proxyRes, clientRes) {
const currMsgCount = parsedBody?.messages?.length || 0;
const thinkingStripped = computeThinkingStripped(isSubagent, reqSessionId, currMsgCount, parsedBody);
const entry = {
id, ts: ctx.ts, sessionId, method: ctx.clientReq.method, url: ctx.clientReq.url,
id, ts: ctx.ts, sessionId, method: ctx.clientReq.method, url: stripAuthParams(ctx.clientReq.url),
provider: 'anthropic',
agent: 'claude',
req: parsedBody, res: events,
Expand Down Expand Up @@ -714,7 +715,7 @@ function handleOpenAISSE(ctx, proxyRes, clientRes) {
const usage = response?.usage || null;

const entry = {
id, ts: ctx.ts, sessionId: reqSessionId, method: ctx.clientReq.method, url: ctx.clientReq.url,
id, ts: ctx.ts, sessionId: reqSessionId, method: ctx.clientReq.method, url: stripAuthParams(ctx.clientReq.url),
provider: 'openai',
agent: 'codex',
req: parsedBody, res: events,
Expand Down Expand Up @@ -856,7 +857,7 @@ function handleNonSSEResponse(ctx, proxyRes, clientRes) {
const responseMetadata = buildResponseMetadata(provider, openAIResponse || resData, proxyRes);
if (openAIEvents) responseMetadata.streaming = true;
const entry = {
id, ts: ctx.ts, sessionId, method: ctx.clientReq.method, url: ctx.clientReq.url,
id, ts: ctx.ts, sessionId, method: ctx.clientReq.method, url: stripAuthParams(ctx.clientReq.url),
provider,
agent: provider === 'openai' ? 'codex' : 'claude',
req: parsedBody, res: resData,
Expand Down
Loading
Loading