Compare commits

...

10 Commits

Author SHA1 Message Date
nirholas
3a854557e0 feat: implement API key authentication and user session management
Some checks failed
CI / Typecheck & Lint (push) Has been cancelled
2026-03-31 12:43:05 +00:00
nirholas
da6c5e1ed7 📝 2026-03-31 12:36:42 +00:00
nirholas
38648ae5f4 ♻️ feat: implement session management for PTY sessions in the server
- Add SessionManager class to handle PTY sessions with WebSocket connections.
- Implement methods for creating, retrieving, and destroying sessions.
- Handle PTY output and WebSocket messages for terminal interaction.
- Ensure graceful session destruction and cleanup.

feat: initialize web application with Next.js and Tailwind CSS

- Create initial Next.js application structure with TypeScript support.
- Set up Tailwind CSS for styling with custom theme configurations.
- Add ESLint configuration for code quality and consistency.

feat: implement chat API and UI components

- Create chat API route to handle chat requests and responses.
- Develop chat layout with sidebar, header, chat window, and input components.
- Integrate Zustand for state management of conversations and messages.
- Add utility functions for formatting dates and managing class names.

chore: add environment variables and configuration files

- Create .env.example for environment variable setup.
- Add configuration files for PostCSS, Tailwind CSS, and TypeScript.
- Set up package.json with necessary dependencies and scripts for development.
2026-03-31 12:35:31 +00:00
nirholas
d31c2bec03 feat: add new message types and update package dependencies
- Added `message.ts` with comprehensive type definitions for various message types including AssistantMessage, UserMessage, and SystemMessage.
- Introduced new dependencies: `react-reconciler` and `auto-bind` in `package.json`.
- Fixed JSON syntax in `server.json` to ensure valid structure.
2026-03-31 11:46:59 +00:00
nirholas
cf482195ff Fix import.meta.dir fallback handling in build scripts 2026-03-31 11:18:17 +00:00
nirholas
ed9c151933 Add TypeScript configuration and local type declarations for build scripts 2026-03-31 11:13:22 +00:00
nirholas
78dd88afd1 Add qrcode dependency to package.json 2026-03-31 11:13:13 +00:00
nirholas
1fc26c8125 Enhance build scripts and add new feature flags for KAIROS and workflow management 2026-03-31 11:09:10 +00:00
nirholas
754fea0e82 Enhance build scripts and add new feature flags in bun-bundle 2026-03-31 11:06:49 +00:00
nirholas
d35ea47ba7 📝 feat: update TypeScript configuration and add API support
- Changed root directory in tsconfig.json to include all source files.
- Updated server.json to include npm package configuration for claude-code-explorer-mcp.
- Enhanced x402 command to support non-interactive mode.
- Refactored x402 command call function to simplify argument handling.
- Introduced .mcp.json for MCP server configuration.
- Added bunfig.toml for Bun development mode configuration.
- Created bridge.md documentation for IDE integration and architecture overview.
- Added .npmignore to exclude unnecessary files from npm package.
- Implemented build-bundle script for production and development builds.
- Developed bun-plugin-shims for Bun preload plugin.
- Created ci-build.sh for CI/CD build pipeline.
- Added dev.ts for development launcher using Bun's TS runtime.
- Implemented package-npm.ts to generate a publishable npm package.
- Created test-auth.ts to verify API key configuration.
- Developed test-mcp.ts for MCP client/server roundtrip testing.
- Implemented test-services.ts to ensure all services initialize correctly.
- Added stub.ts for bridge functionality when BRIDGE_MODE is disabled.
2026-03-31 10:59:36 +00:00
215 changed files with 42044 additions and 34 deletions

32
.dockerignore Normal file
View File

@@ -0,0 +1,32 @@
# Dependencies (rebuilt in container)
node_modules
# Git metadata
.git
.github
.gitignore
# Build output (rebuilt in container)
dist
# Env files — never bake secrets into the image
.env
.env.*
# Logs and debug
*.log
npm-debug.log*
bun-debug.log*
# Test artifacts
coverage
.nyc_output
# Editor / OS noise
.DS_Store
Thumbs.db
.vscode
.idea
# Docker context itself
docker

View File

@@ -3,15 +3,61 @@
# =============================================================================
# Copy this file to .env and fill in the values you need.
# All variables are optional unless noted otherwise.
#
# ─── HOW AUTHENTICATION WORKS ───────────────────────────────────────────────
#
# The CLI supports multiple authentication backends, resolved in this order:
#
# 1. PROVIDER SELECTION (src/utils/model/providers.ts — getAPIProvider()):
# Checks CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, or
# CLAUDE_CODE_USE_FOUNDRY. If set, routes to that 3rd-party provider.
# Otherwise uses Direct Anthropic API.
#
# 2. OAUTH vs API KEY DECISION (src/utils/auth.ts — isAnthropicAuthEnabled()):
# OAuth is DISABLED (API-key-only mode) when any of these are true:
# - --bare flag is used (sets CLAUDE_CODE_SIMPLE=1)
# - CLAUDE_CODE_USE_BEDROCK / VERTEX / FOUNDRY is set
# - ANTHROPIC_API_KEY env var is set (and not in managed OAuth context)
# - ANTHROPIC_AUTH_TOKEN env var is set
# - An apiKeyHelper is configured in settings
# If none of the above, OAuth is attempted via ~/.claude/.credentials.json.
#
# 3. API KEY RESOLUTION (src/utils/auth.ts — getAnthropicApiKey()):
# Priority order when OAuth is not active:
# a. CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR (pipe-passed key)
# b. apiKeyHelper (configured external command in settings)
# c. ANTHROPIC_API_KEY env var
# d. System keychain
# e. ~/.claude/.credentials config
#
# 4. CLIENT CONSTRUCTION (src/services/api/client.ts — getAnthropicClient()):
# Creates the appropriate SDK client (Anthropic, Bedrock, Vertex, or
# Foundry) using the resolved auth credentials plus any custom headers
# from ANTHROPIC_CUSTOM_HEADERS.
#
# FOR DEVELOPMENT: The simplest path is to set ANTHROPIC_API_KEY below.
# The auth system will detect the external key and skip OAuth entirely.
# =============================================================================
# ── Authentication ───────────────────────────────────────────────────────────
# Your Anthropic API key (required for direct API access)
ANTHROPIC_API_KEY=
# Bearer auth token (alternative to API key — used by bridge/remote)
# ANTHROPIC_AUTH_TOKEN=
# Custom API base URL (default: https://api.anthropic.com)
# ANTHROPIC_BASE_URL=
# Custom headers sent with every API request (multiline, "Name: Value" per line)
# ANTHROPIC_CUSTOM_HEADERS=
# Add additional protection header to API requests
# CLAUDE_CODE_ADDITIONAL_PROTECTION=true
# Pipe-pass API key via file descriptor (advanced — for managed environments)
# CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR=
# ── Model Selection ──────────────────────────────────────────────────────────
# Override the default model
# ANTHROPIC_MODEL=
@@ -38,16 +84,67 @@ ANTHROPIC_API_KEY=
# Model for sub-agents / teammates
# CLAUDE_CODE_SUBAGENT_MODEL=
# ── Alternative Providers ────────────────────────────────────────────────────
# Use AWS Bedrock instead of direct Anthropic API
# ── AWS Bedrock ──────────────────────────────────────────────────────────────
# Enable Bedrock backend (uses AWS SDK default credentials: IAM, profile, env)
# CLAUDE_CODE_USE_BEDROCK=true
# Custom Bedrock endpoint URL
# ANTHROPIC_BEDROCK_BASE_URL=
# AWS region for Bedrock (default: us-east-1)
# AWS_REGION=us-east-1
# AWS_DEFAULT_REGION=us-east-1
# Override AWS region specifically for the small fast model (Haiku)
# ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION=
# Bearer token auth for Bedrock (alternative to IAM)
# AWS_BEARER_TOKEN_BEDROCK=
# Skip Bedrock auth (for testing without real AWS credentials)
# CLAUDE_CODE_SKIP_BEDROCK_AUTH=false
# Use Google Vertex AI
# ── Google Vertex AI ─────────────────────────────────────────────────────────
# Enable Vertex AI backend
# CLAUDE_CODE_USE_VERTEX=true
# Required: Your GCP project ID
# ANTHROPIC_VERTEX_PROJECT_ID=
# Default GCP region for all models
# CLOUD_ML_REGION=us-east5
# Model-specific region overrides (highest priority)
# VERTEX_REGION_CLAUDE_3_5_HAIKU=
# VERTEX_REGION_CLAUDE_HAIKU_4_5=
# VERTEX_REGION_CLAUDE_3_5_SONNET=
# VERTEX_REGION_CLAUDE_3_7_SONNET=
# Custom Vertex base URL
# ANTHROPIC_VERTEX_BASE_URL=
# GCP service account credentials JSON file
# GOOGLE_APPLICATION_CREDENTIALS=
# Skip Vertex auth (for testing without real GCP credentials)
# CLAUDE_CODE_SKIP_VERTEX_AUTH=false
# ── Azure Foundry ────────────────────────────────────────────────────────────
# Enable Azure Foundry backend
# CLAUDE_CODE_USE_FOUNDRY=true
# Azure resource name (creates URL: https://{resource}.services.ai.azure.com/...)
# ANTHROPIC_FOUNDRY_RESOURCE=
# Alternative: provide full base URL directly instead of resource name
# ANTHROPIC_FOUNDRY_BASE_URL=
# Foundry API key (if not set, uses Azure AD / DefaultAzureCredential)
# ANTHROPIC_FOUNDRY_API_KEY=
# Skip Foundry auth (for testing without real Azure credentials)
# CLAUDE_CODE_SKIP_FOUNDRY_AUTH=false
# ── Shell & Environment ─────────────────────────────────────────────────────
# Override shell used for BashTool (default: auto-detected)
# CLAUDE_CODE_SHELL=/bin/bash
@@ -75,7 +172,7 @@ ANTHROPIC_API_KEY=
# NODE_OPTIONS=--max-old-space-size=8192
# ── Features & Modes ────────────────────────────────────────────────────────
# Enable simplified/worker mode
# Enable simplified/worker mode (also set by --bare flag)
# CLAUDE_CODE_SIMPLE=true
# Enable coordinator (multi-agent) mode
@@ -119,10 +216,24 @@ ANTHROPIC_API_KEY=
# Environment kind (e.g. bridge)
# CLAUDE_CODE_ENVIRONMENT_KIND=
# OAuth tokens for bridge
# OAuth token injected by bridge/CCR
# CLAUDE_CODE_OAUTH_TOKEN=
# OAuth refresh token for bridge
# CLAUDE_CODE_OAUTH_REFRESH_TOKEN=
# OAuth scopes
# CLAUDE_CODE_OAUTH_SCOPES=
# Session access token for remote
# CLAUDE_CODE_SESSION_ACCESS_TOKEN=
# Unix socket for SSH remote auth
# ANTHROPIC_UNIX_SOCKET=
# Entrypoint identifier (cli, mcp, remote, sdk-*, etc.)
# CLAUDE_CODE_ENTRYPOINT=cli
# ── Debugging ────────────────────────────────────────────────────────────────
# Debug log level (error, warn, info, debug, trace)
# CLAUDE_CODE_DEBUG_LOG_LEVEL=info
@@ -150,7 +261,37 @@ ANTHROPIC_API_KEY=
# Custom SSL certificate
# SSL_CERT_FILE=
# Unix socket for Anthropic API
# ANTHROPIC_UNIX_SOCKET=
# =============================================================================
# ─── OAUTH STUB NOTES (Part D) ─────────────────────────────────────────────
# =============================================================================
#
# The OAuth flow (src/services/oauth/) requires a browser and Anthropic's
# OAuth endpoints. For development, you do NOT need to stub anything —
# just set ANTHROPIC_API_KEY and the auth system will skip OAuth automatically.
#
# How it works:
# src/utils/auth.ts — isAnthropicAuthEnabled() checks whether OAuth should
# be attempted. When it detects ANTHROPIC_API_KEY in the environment (and
# the context is not a "managed OAuth" context like Claude Desktop), it
# returns false, which causes the entire OAuth path to be bypassed.
#
# Decision chain:
# 1. isAnthropicAuthEnabled() in src/utils/auth.ts (line ~111)
# → returns false if external API key is detected
# 2. isClaudeAISubscriber() in src/utils/auth.ts (line ~1715)
# → checks OAuth tokens for user:inference scope (never reached if #1 is false)
# 3. getAnthropicApiKey() in src/utils/auth.ts (line ~201)
# → resolves API key from env/keychain/config
#
# If you needed to FORCE bypass OAuth in all cases (e.g. for testing the
# OAuth code paths themselves), you could:
# - Use --bare flag (sets CLAUDE_CODE_SIMPLE=1, disables OAuth in isBareMode())
# - Set CLAUDE_CODE_USE_BEDROCK=true + CLAUDE_CODE_SKIP_BEDROCK_AUTH=true
# (routes around OAuth entirely but creates a Bedrock-shaped client)
# - Directly modify isAnthropicAuthEnabled() to return false (not recommended
# for production)
#
# Bottom line: export ANTHROPIC_API_KEY="sk-ant-..." is sufficient for dev.
# =============================================================================

6
.gitignore vendored
View File

@@ -12,6 +12,9 @@ build/
.env.local
.env.*.local
# Prompts
prompts/
# OS files
.DS_Store
Thumbs.db
@@ -32,6 +35,9 @@ coverage/
# Bun
bun.lockb
# MCP registry tokens
.mcpregistry_*
# Temporary files
tmp/
temp/

11
.mcp.json Normal file
View File

@@ -0,0 +1,11 @@
{
"mcpServers": {
"claude-code-explorer": {
"command": "node",
"args": ["mcp-server/dist/index.js"],
"env": {
"CLAUDE_CODE_SRC_ROOT": "./src"
}
}
}
}

View File

@@ -1,31 +1,45 @@
# ─────────────────────────────────────────────────────────────
# Claude Code CLI — Development Container
# Claude Code CLI — Production Container
# ─────────────────────────────────────────────────────────────
# This image provides a ready-to-explore environment for the
# leaked source. It does NOT produce a runnable build (the
# original build tooling was not included in the leak).
# Multi-stage build: builds a production bundle, then copies
# only the output into a minimal runtime image.
#
# Usage:
# docker build -t claude-code .
# docker run --rm -e ANTHROPIC_API_KEY=sk-... claude-code -p "hello"
# ─────────────────────────────────────────────────────────────
FROM oven/bun:1-alpine AS base
# Stage 1: Build
FROM oven/bun:1-alpine AS builder
WORKDIR /app
# Install OS-level dependencies used at runtime
RUN apk add --no-cache git ripgrep
# Copy manifests first for layer caching
COPY package.json bun.lockb* ./
# Install npm packages
# Install all dependencies (including devDependencies for build)
RUN bun install --frozen-lockfile || bun install
# Copy source
COPY . .
# Typecheck (optional — fails loudly if deps are wrong)
# RUN bun run typecheck
# Build production bundle
RUN bun run build:prod
# Default: drop into a shell for exploration
CMD ["sh"]
# Stage 2: Runtime
FROM oven/bun:1-alpine
WORKDIR /app
# Install OS-level runtime dependencies
RUN apk add --no-cache git ripgrep
# Copy only the bundled output from the builder
COPY --from=builder /app/dist/cli.mjs /app/cli.mjs
# Make it executable
RUN chmod +x /app/cli.mjs
ENTRYPOINT ["bun", "/app/cli.mjs"]

View File

@@ -9,6 +9,7 @@
[![React + Ink](https://img.shields.io/badge/UI-React_%2B_Ink-61DAFB?logo=react&logoColor=black)](#tech-stack)
[![Files](https://img.shields.io/badge/~1,900_files-source_only-grey)](#directory-structure)
[![MCP Server](https://img.shields.io/badge/MCP-Explorer_Server-blueviolet)](#-explore-with-mcp-server)
[![npm](https://img.shields.io/npm/v/claude-code-explorer-mcp?label=npm&color=cb3837&logo=npm)](https://www.npmjs.com/package/claude-code-explorer-mcp)
> The original unmodified leaked source is preserved in the [`backup` branch](https://github.com/nirholas/claude-code/tree/backup).
@@ -83,7 +84,16 @@ Also see: [CONTRIBUTING.md](CONTRIBUTING.md) · [MCP Server README](mcp-server/R
This repo ships an [MCP server](https://modelcontextprotocol.io/) that lets any MCP-compatible client (Claude Code, Claude Desktop, VS Code Copilot, Cursor) explore the full source interactively.
### One-liner setup
### Install from npm
The MCP server is published as [`claude-code-explorer-mcp`](https://www.npmjs.com/package/claude-code-explorer-mcp) on npm — no need to clone the repo:
```bash
# Claude Code
claude mcp add claude-code-explorer -- npx -y claude-code-explorer-mcp
```
### One-liner setup (from source)
```bash
git clone https://github.com/nirholas/claude-code.git ~/claude-code \

635
bun.lock Normal file
View File

@@ -0,0 +1,635 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "@anthropic-ai/claude-code",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@commander-js/extra-typings": "^13.1.0",
"@growthbook/growthbook": "^1.4.0",
"@modelcontextprotocol/sdk": "^1.12.1",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.57.0",
"@opentelemetry/core": "^1.30.0",
"@opentelemetry/sdk-logs": "^0.57.0",
"@opentelemetry/sdk-metrics": "^1.30.0",
"@opentelemetry/sdk-trace-base": "^1.30.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"auto-bind": "^5.0.1",
"axios": "^1.7.0",
"chalk": "^5.4.0",
"chokidar": "^4.0.0",
"cli-boxes": "^3.0.0",
"code-excerpt": "^4.0.0",
"diff": "^7.0.0",
"execa": "^9.5.0",
"figures": "^6.1.0",
"fuse.js": "^7.0.0",
"highlight.js": "^11.11.0",
"ignore": "^6.0.0",
"lodash-es": "^4.17.21",
"marked": "^15.0.0",
"node-pty": "^1.1.0",
"p-map": "^7.0.0",
"picomatch": "^4.0.0",
"proper-lockfile": "^4.1.2",
"qrcode": "^1.5.0",
"react": "^19.0.0",
"react-reconciler": "^0.31.0",
"semver": "^7.6.0",
"stack-utils": "^2.0.6",
"strip-ansi": "^7.1.0",
"supports-hyperlinks": "^3.1.0",
"tree-kill": "^1.2.2",
"type-fest": "^4.30.0",
"undici": "^7.3.0",
"usehooks-ts": "^3.1.0",
"wrap-ansi": "^9.0.0",
"ws": "^8.18.0",
"yaml": "^2.6.0",
"zod": "^3.24.0",
},
"devDependencies": {
"@biomejs/biome": "^1.9.0",
"@types/diff": "^7.0.0",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.10.0",
"@types/picomatch": "^3.0.0",
"@types/proper-lockfile": "^4.1.4",
"@types/react": "^19.0.0",
"@types/semver": "^7.5.8",
"@types/stack-utils": "^2.0.3",
"@types/ws": "^8.5.0",
"esbuild": "^0.25.0",
"typescript": "^5.7.0",
},
},
},
"packages": {
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="],
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
"@commander-js/extra-typings": ["@commander-js/extra-typings@13.1.0", "", { "peerDependencies": { "commander": "~13.1.0" } }, "sha512-q5P52BYb1hwVWE6dtID7VvuJWrlfbCv4klj7BjUUOqMz4jbSZD4C9fJ9lRjL2jnBGTg+gDDlaXN51rkWcLk4fg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="],
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="],
"@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="],
"@opentelemetry/resources": ["@opentelemetry/resources@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA=="],
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg=="],
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog=="],
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg=="],
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"@types/diff": ["@types/diff@7.0.2", "", {}, "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q=="],
"@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
"@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@types/picomatch": ["@types/picomatch@3.0.2", "", {}, "sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA=="],
"@types/proper-lockfile": ["@types/proper-lockfile@4.1.4", "", { "dependencies": { "@types/retry": "*" } }, "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/retry": ["@types/retry@0.12.5", "", {}, "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw=="],
"@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@xterm/addon-fit": ["@xterm/addon-fit@0.10.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ=="],
"@xterm/addon-search": ["@xterm/addon-search@0.15.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg=="],
"@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.8.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q=="],
"@xterm/addon-web-links": ["@xterm/addon-web-links@0.11.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q=="],
"@xterm/addon-webgl": ["@xterm/addon-webgl@0.18.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w=="],
"@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" }, "peerDependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
"axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"dom-mutator": ["dom-mutator@0.6.0", "", {}, "sha512-iCt9o0aYfXMUkz/43ZOAUFQYotjGB+GNbYJiJdz4TgXkyToXbbRy5S6FbTp72lRBtfpUMwEc1KmpFEU4CZeoNg=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ignore": ["ignore@6.0.2", "", {}, "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
"marked": ["marked@15.0.12", "", { "bin": "bin/marked.js" }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="],
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@8.4.1", "", {}, "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": "bin/qrcode" }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-reconciler": ["react-reconciler@0.31.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="],
"semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": "cli.js" }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@7.24.6", "", {}, "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"usehooks-ts": ["usehooks-ts@3.1.1", "", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"yaml": ["yaml@2.8.3", "", { "bin": "bin.mjs" }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
}
}

4
bunfig.toml Normal file
View File

@@ -0,0 +1,4 @@
# bunfig.toml — Bun configuration for development mode
# The plugin intercepts `bun:bundle` imports → src/shims/bun-bundle.ts
preload = ["./scripts/bun-plugin-shims.ts"]

35
docker/.dockerignore Normal file
View File

@@ -0,0 +1,35 @@
# NOTE: Docker reads .dockerignore from the build context root.
# The canonical copy lives at /.dockerignore — keep both in sync.
# Dependencies (rebuilt in container)
node_modules
# Git metadata
.git
.github
.gitignore
# Build output (rebuilt in container)
dist
# Env files — never bake secrets into the image
.env
.env.*
# Logs and debug
*.log
npm-debug.log*
bun-debug.log*
# Test artifacts
coverage
.nyc_output
# Editor / OS noise
.DS_Store
Thumbs.db
.vscode
.idea
# Docker context itself
docker

83
docker/Dockerfile Normal file
View File

@@ -0,0 +1,83 @@
# ─────────────────────────────────────────────────────────────
# Claude Web Terminal — Production Container
# ─────────────────────────────────────────────────────────────
# Multi-stage build: compiles node-pty native module and bundles
# the Claude CLI, then copies artifacts into a slim runtime image.
#
# Usage:
# docker build -f docker/Dockerfile -t claude-web .
# docker run -p 3000:3000 -e ANTHROPIC_API_KEY=sk-ant-... claude-web
# ─────────────────────────────────────────────────────────────
# ── Stage 1: Build ────────────────────────────────────────────
FROM oven/bun:1 AS builder
WORKDIR /app
# Build tools required to compile node-pty's native C++ addon
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
# Copy manifests first for layer caching
COPY package.json bun.lockb* ./
# Install all deps (triggers node-pty native compilation)
RUN bun install --frozen-lockfile 2>/dev/null || bun install
# Copy source tree
COPY . .
# Bundle the Claude CLI (produces dist/cli.mjs)
RUN bun run build:prod
# ── Stage 2: Runtime ──────────────────────────────────────────
FROM oven/bun:1 AS runtime
WORKDIR /app
# curl for health checks; no build tools needed at runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Non-root user that PTY sessions will run under
RUN groupadd -r claude && useradd -r -g claude -m -d /home/claude claude
# Compiled node_modules (includes native node-pty binary)
COPY --from=builder /app/node_modules ./node_modules
# Bundled Claude CLI
COPY --from=builder /app/dist ./dist
# PTY server source (bun runs TypeScript natively)
COPY --from=builder /app/src/server ./src/server
# TypeScript config needed for bun's module resolution
COPY --from=builder /app/tsconfig.json ./tsconfig.json
# Thin wrapper so the PTY server can exec `claude` as a subprocess
RUN printf '#!/bin/sh\nexec bun /app/dist/cli.mjs "$@"\n' \
> /usr/local/bin/claude && chmod +x /usr/local/bin/claude
# Entrypoint script
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Allow the claude user to write its config into its home dir
RUN chown -R claude:claude /home/claude
# ── Defaults ──────────────────────────────────────────────────
ENV NODE_ENV=production \
PORT=3000 \
MAX_SESSIONS=5 \
CLAUDE_BIN=claude
EXPOSE 3000
USER claude
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:${PORT:-3000}/health || exit 1
ENTRYPOINT ["/entrypoint.sh"]

28
docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,28 @@
services:
claude-web:
build:
context: ..
dockerfile: docker/Dockerfile
ports:
- "${PORT:-3000}:3000"
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- AUTH_TOKEN=${AUTH_TOKEN:-}
- MAX_SESSIONS=${MAX_SESSIONS:-5}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
volumes:
# Persist Claude's config and session data across restarts
- claude-data:/home/claude/.claude
tmpfs:
# PTY processes write temp files here; no persistent storage needed
- /tmp:mode=1777
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
claude-data:

28
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,28 @@
#!/bin/sh
set -e
# ── Validate required env vars ────────────────────────────────
if [ -z "$ANTHROPIC_API_KEY" ]; then
echo "ERROR: ANTHROPIC_API_KEY is not set." >&2
echo "" >&2
echo " docker run -p 3000:3000 -e ANTHROPIC_API_KEY=sk-ant-... claude-web" >&2
echo "" >&2
echo " Or via docker-compose with a .env file:" >&2
echo " ANTHROPIC_API_KEY=sk-ant-... docker-compose up" >&2
exit 1
fi
# The API key is forwarded to child PTY processes via process.env,
# so the claude CLI will pick it up automatically — no config file needed.
echo "Claude Web Terminal starting on port ${PORT:-3000}..."
if [ -n "$AUTH_TOKEN" ]; then
echo " Auth token protection: enabled"
fi
if [ -n "$ALLOWED_ORIGINS" ]; then
echo " Allowed origins: $ALLOWED_ORIGINS"
fi
echo " Max sessions: ${MAX_SESSIONS:-5}"
# Hand off to the PTY WebSocket server
exec bun /app/src/server/web/pty-server.ts

239
docs/bridge.md Normal file
View File

@@ -0,0 +1,239 @@
# Bridge Layer (VS Code / JetBrains IDE Integration)
## Architecture Overview
The bridge (`src/bridge/`, ~31 files) connects Claude Code CLI sessions to
remote IDE extensions (VS Code, JetBrains) and the claude.ai web UI. It is
gated behind `feature('BRIDGE_MODE')` which defaults to `false`.
### Protocols
The bridge uses **two transport generations**:
| Version | Read Path | Write Path | Negotiation |
|---------|-----------|------------|-------------|
| **v1 (env-based)** | WebSocket to Session-Ingress (`ws(s)://.../v1/session_ingress/ws/{sessionId}`) | HTTP POST to Session-Ingress | Environments API poll/ack/dispatch |
| **v2 (env-less)** | SSE stream via `SSETransport` | `CCRClient``/worker/*` endpoints | Direct `POST /v1/code/sessions/{id}/bridge` → worker JWT |
Both wrapped behind `ReplBridgeTransport` interface (`replBridgeTransport.ts`).
The v1 path: register environment → poll for work → acknowledge → spawn session.
The v2 path: create session → POST `/bridge` for JWT → SSE + CCRClient directly.
### Authentication
1. **OAuth tokens** — claude.ai subscription required (`isClaudeAISubscriber()`)
2. **JWT** — Session-Ingress tokens (`sk-ant-si-` prefixed) with `exp` claims.
`jwtUtils.ts` decodes and schedules proactive refresh before expiry.
3. **Trusted Device token**`X-Trusted-Device-Token` header for elevated
security tier sessions. Enrolled via `trustedDevice.ts`.
4. **Environment secret** — base64url-encoded `WorkSecret` containing
`session_ingress_token`, `api_base_url`, git sources, auth tokens.
Dev override: `CLAUDE_BRIDGE_OAUTH_TOKEN` and `CLAUDE_BRIDGE_BASE_URL`
(ant-only, `process.env.USER_TYPE === 'ant'`).
### Message Flow (IDE ↔ CLI)
```
IDE / claude.ai ──WebSocket/SSE──→ Session-Ingress ──→ CLI (replBridge)
←── POST / CCRClient writes ──── Session-Ingress ←── CLI
```
**Inbound** (server → CLI):
- `user` messages (prompts from web UI) → `handleIngressMessage()` → enqueued to REPL
- `control_request` (initialize, set_model, interrupt, set_permission_mode, set_max_thinking_tokens)
- `control_response` (permission decisions from IDE)
**Outbound** (CLI → server):
- `assistant` messages (Claude's responses)
- `user` messages (echoed for sync)
- `result` messages (turn completion)
- System events, tool starts, activities
Dedup: `BoundedUUIDSet` tracks recent posted/inbound UUIDs to reject echoes
and re-deliveries.
### Lifecycle
1. **Entitlement check**: `isBridgeEnabled()` / `isBridgeEnabledBlocking()`
GrowthBook gate `tengu_ccr_bridge` + OAuth subscriber check
2. **Session creation**: `createBridgeSession()` → POST to API
3. **Transport init**: v1 `HybridTransport` or v2 `SSETransport` + `CCRClient`
4. **Message pump**: Read inbound via transport, write outbound via batch
5. **Token refresh**: Proactive JWT refresh via `createTokenRefreshScheduler()`
6. **Teardown**: `teardown()` → flush pending → close transport → archive session
Spawn modes for `claude remote-control`:
- `single-session`: One session in cwd, bridge tears down when it ends
- `worktree`: Persistent server, each session gets an isolated git worktree
- `same-dir`: Persistent server, sessions share cwd
### Key Types
- `BridgeConfig` — Full bridge configuration (dir, auth, URLs, spawn mode, timeouts)
- `WorkSecret` — Decoded work payload (token, API URL, git sources, MCP config)
- `SessionHandle` — Running session (kill, activities, stdin, token update)
- `ReplBridgeHandle` — REPL bridge API (write messages, control requests, teardown)
- `BridgeState``'ready' | 'connected' | 'reconnecting' | 'failed'`
- `SpawnMode``'single-session' | 'worktree' | 'same-dir'`
---
## Feature Gate Analysis
### Must Work (currently works correctly)
The `feature('BRIDGE_MODE')` gate in `src/shims/bun-bundle.ts` defaults to
`false` (reads `CLAUDE_CODE_BRIDGE_MODE` env var). All critical code paths
are properly guarded:
| Location | Guard |
|----------|-------|
| `src/entrypoints/cli.tsx:112` | `feature('BRIDGE_MODE') && args[0] === 'remote-control'` |
| `src/main.tsx:2246` | `feature('BRIDGE_MODE') && remoteControlOption !== undefined` |
| `src/main.tsx:3866` | `if (feature('BRIDGE_MODE'))` (Commander subcommand) |
| `src/hooks/useReplBridge.tsx:79-88` | All `useAppState` calls gated by `feature('BRIDGE_MODE')` ternary |
| `src/hooks/useReplBridge.tsx:99` | `useEffect` body gated by `feature('BRIDGE_MODE')` |
| `src/components/PromptInput/PromptInputFooter.tsx:160` | `if (!feature('BRIDGE_MODE')) return null` |
| `src/components/Settings/Config.tsx:930` | `feature('BRIDGE_MODE') && isBridgeEnabled()` spread |
| `src/tools/BriefTool/upload.ts:99` | `if (feature('BRIDGE_MODE'))` |
| `src/tools/ConfigTool/supportedSettings.ts:153` | `feature('BRIDGE_MODE')` spread |
### Can Defer (full bridge functionality)
All of the following are behind the feature gate and inactive:
- `runBridgeLoop()` — Full bridge orchestration in `bridgeMain.ts`
- `initReplBridge()` — REPL bridge initialization
- `initBridgeCore()` / `initEnvLessBridgeCore()` — Transport negotiation
- `createBridgeApiClient()` — Environments API calls
- `BridgeUI` — Bridge status display and QR codes
- Token refresh scheduling
- Multi-session management (worktree mode)
- Permission delegation to IDE
### Won't Break
Static imports of bridge modules from outside `src/bridge/` do NOT crash because:
1. **All bridge files exist** — they're in the repo, so imports resolve.
2. **No side effects at import time** — bridge modules define functions/types
but don't execute bridge logic on import.
3. **Runtime guards** — Functions like `isBridgeEnabled()` return `false`
when `feature('BRIDGE_MODE')` is false. `getReplBridgeHandle()` returns
`null`. `useReplBridge` short-circuits via ternary operators.
Files with unguarded static imports (safe because files exist):
- `src/hooks/useReplBridge.tsx` — imports types and utils from bridge
- `src/components/Settings/Config.tsx` — imports `isBridgeEnabled` (returns false)
- `src/components/PromptInput/PromptInputFooter.tsx` — early-returns null
- `src/tools/SendMessageTool/SendMessageTool.ts``getReplBridgeHandle()` returns null
- `src/tools/BriefTool/upload.ts` — guarded at call site
- `src/commands/logout/logout.tsx``clearTrustedDeviceTokenCache` is a no-op
---
## Bridge Stub
Created `src/bridge/stub.ts` with:
- `isBridgeAvailable()` → always returns `false`
- `noopBridgeHandle` — silent no-op `ReplBridgeHandle`
- `noopBridgeLogger` — silent no-op `BridgeLogger`
Available for any future code that needs a safe fallback when bridge is off.
---
## Bridge Activation (Future Work)
To enable the bridge:
### 1. Environment Variable
```bash
export CLAUDE_CODE_BRIDGE_MODE=true
```
### 2. Authentication Requirements
- Must be logged in to claude.ai with an active subscription
(`isClaudeAISubscriber()` must return `true`)
- OAuth tokens obtained via `claude auth login` (needs `user:profile` scope)
- GrowthBook gate `tengu_ccr_bridge` must be enabled for the user's org
### 3. IDE Extension
- VS Code: Claude Code extension (connects via the bridge's Session-Ingress layer)
- JetBrains: Similar integration (same protocol)
- Web: `claude.ai/code?bridge={environmentId}` URL
### 4. Network / Ports
- **Session-Ingress**: WebSocket (`wss://`) or SSE for reads; HTTPS POST for writes
- **API base**: Production `api.claude.ai` (configured via OAuth config)
- Dev overrides: `CLAUDE_BRIDGE_BASE_URL`, localhost uses `ws://` and `/v2/` paths
- QR code displayed in terminal links to `claude.ai/code?bridge={envId}`
### 5. Running Remote Control
```bash
# Single session (tears down when session ends)
claude remote-control
# Named session
claude remote-control "my-project"
# With specific spawn mode (requires tengu_ccr_bridge_multi_session gate)
claude remote-control --spawn worktree
claude remote-control --spawn same-dir
```
### 6. Additional Flags
- `--remote-control [name]` / `--rc [name]` — Start REPL with bridge pre-enabled
- `--debug-file <path>` — Write debug log to file
- `--session-id <id>` — Resume an existing session
---
## Chrome Extension Bridge
### `--claude-in-chrome-mcp` (cli.tsx:72)
Launches a **Claude-in-Chrome MCP server** via `runClaudeInChromeMcpServer()` from
`src/utils/claudeInChrome/mcpServer.ts`. This:
- Creates a `StdioServerTransport` (MCP over stdin/stdout)
- Uses `@ant/claude-for-chrome-mcp` package to create an MCP server
- Bridges between Claude Code and the Chrome extension
- Supports both native socket (local) and WebSocket bridge (`wss://bridge.claudeusercontent.com`)
- Gated by `tengu_copper_bridge` GrowthBook flag (or `USER_TYPE=ant`)
**Not gated by `feature('BRIDGE_MODE')`** — this is a separate subsystem. It only
runs when explicitly invoked with `--claude-in-chrome-mcp` flag.
### `--chrome-native-host` (cli.tsx:79)
Launches the **Chrome Native Messaging Host** via `runChromeNativeHost()` from
`src/utils/claudeInChrome/chromeNativeHost.ts`. This:
- Implements Chrome's native messaging protocol (4-byte length prefix + JSON over stdin/stdout)
- Creates a Unix domain socket server at a secure path
- Proxies MCP messages between Chrome extension and local Claude Code instances
- Has its own debug logging to `~/.claude/debug/chrome-native-host.txt` (ant-only)
**Not gated by `feature('BRIDGE_MODE')`** — separate entry point. Only activated
when Chrome calls the registered native messaging host binary.
### Safety
Both Chrome paths:
- Are **dynamic imports** — only loaded when the specific flag is passed
- Return immediately after their own `await` — no side effects on normal CLI startup
- Cannot crash normal operation because they're entirely separate code paths
- Have no dependency on the bridge feature flag
---
## Verification Summary
| Check | Status |
|-------|--------|
| `feature('BRIDGE_MODE')` returns `false` by default | ✅ Verified in `src/shims/bun-bundle.ts` |
| Bridge code not executed when disabled | ✅ All call sites use `feature()` guard |
| No bridge-related errors on startup | ✅ Imports resolve (files exist), no side effects |
| CLI works in terminal-only mode | ✅ Bridge is purely additive |
| Chrome paths don't crash | ✅ Separate dynamic imports, only on explicit flags |
| Stub available for safety | ✅ Created `src/bridge/stub.ts` |

4
mcp-server/.npmignore Normal file
View File

@@ -0,0 +1,4 @@
.mcpregistry_*
src/
tsconfig.json
*.ts.new

View File

@@ -1,10 +1,18 @@
# Claude Code Explorer — MCP Server
A standalone [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets any MCP-compatible client explore the Claude Code source code.
A standalone [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets any MCP-compatible client explore the Claude Code source code. Supports **STDIO**, **Streamable HTTP**, and **SSE** transports.
## What It Does
Exposes 7 tools, 3 resources, and 5 prompts for navigating the ~1,900-file, 512K+ line Claude Code codebase:
Exposes 8 tools, 3 resources, and 5 prompts for navigating the ~1,900-file, 512K+ line Claude Code codebase:
### Transports
| Transport | Endpoint | Best For |
|-----------|----------|----------|
| **STDIO** | `node dist/index.js` | Claude Desktop, local Claude Code, VS Code |
| **Streamable HTTP** | `POST/GET /mcp` | Modern MCP clients, remote hosting |
| **Legacy SSE** | `GET /sse` + `POST /messages` | Older MCP clients |
### Tools
@@ -46,6 +54,30 @@ npm install
npm run build
```
### Run Locally (STDIO)
```bash
npm start
# or with custom source path:
CLAUDE_CODE_SRC_ROOT=/path/to/src npm start
```
### Run Locally (HTTP)
```bash
npm run start:http
# Streamable HTTP at http://localhost:3000/mcp
# Legacy SSE at http://localhost:3000/sse
# Health check at http://localhost:3000/health
```
### With Authentication
```bash
MCP_API_KEY=your-secret-token npm run start:http
# Clients must include: Authorization: Bearer your-secret-token
```
## Configuration
### Claude Desktop
@@ -108,6 +140,65 @@ Add to `~/.cursor/mcp.json`:
| Variable | Default | Description |
|----------|---------|-------------|
| `CLAUDE_CODE_SRC_ROOT` | `../src` (relative to dist/) | Path to the Claude Code `src/` directory |
| `PORT` | `3000` | HTTP server port (HTTP mode only) |
| `MCP_API_KEY` | _(none)_ | Bearer token for HTTP auth (optional) |
## Remote HTTP Client Configuration
For Claude Desktop connecting to a remote server:
```json
{
"mcpServers": {
"claude-code-explorer": {
"url": "https://your-deployment.railway.app/mcp",
"headers": {
"Authorization": "Bearer your-secret-key"
}
}
}
}
```
## Deployment
### Railway
1. Connect your GitHub repo to [Railway](https://railway.app)
2. Railway automatically detects the `mcp-server/Dockerfile`
3. Set environment variables in the Railway dashboard:
- `MCP_API_KEY` — a secret bearer token
- `PORT` is set automatically by Railway
4. Deploy — available at `your-app.railway.app`
Or via CLI:
```bash
railway init
railway up
```
### Vercel
```bash
npx vercel
```
Set environment variables in the Vercel dashboard:
- `CLAUDE_CODE_SRC_ROOT` — path where src/ files are bundled
- `MCP_API_KEY` — bearer token
> **Note**: Vercel functions are stateless with execution time limits (10s hobby / 60s pro). Best for simple tool calls. For persistent SSE streams, use Railway or Docker.
### Docker
```bash
# From repo root
docker build -f mcp-server/Dockerfile -t claude-code-mcp .
docker run -p 3000:3000 -e MCP_API_KEY=your-secret claude-code-mcp
```
Works on any Docker host: Fly.io, Render, AWS ECS, Google Cloud Run, etc.
## Prompts
@@ -162,9 +253,27 @@ Registry name: `io.github.nirholas/claude-code-explorer-mcp`
```bash
npm install
npm run dev # Run directly with tsx (no build needed)
npm run dev # Watch mode TypeScript compilation
npm run build # Compile TypeScript to dist/
npm start # Run compiled server
npm start # Run STDIO server
npm run start:http # Run HTTP server
```
## Architecture
```
mcp-server/
├── src/
│ ├── server.ts — Shared MCP server (tools, resources, prompts) — transport-agnostic
│ ├── index.ts — STDIO entrypoint (local)
│ └── http.ts — HTTP + SSE entrypoint (remote)
├── api/
│ ├── index.ts — Vercel serverless function
│ └── vercelApp.ts — Express app for Vercel
├── Dockerfile — Docker build (Railway, Fly.io, etc.)
├── railway.json — Railway deployment config
├── package.json
└── tsconfig.json
```

View File

@@ -2,7 +2,7 @@
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.nirholas/claude-code-explorer-mcp",
"title": "Claude Code Explorer MCP",
"description": "MCP server for exploring the Claude Code CLI source code — browse 40+ tools, 50+ commands, search the full 512K-line codebase, and get architecture overviews.",
"description": "Explore the Claude Code CLI source — browse tools, commands, search code, and more.",
"repository": {
"url": "https://github.com/nirholas/claude-code",
"source": "github",

View File

@@ -4,7 +4,7 @@
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
@@ -14,7 +14,7 @@
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"include": ["src/**/*", "api/**/*"],
"exclude": ["node_modules", "dist"]
}

985
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,12 @@
"claude": "src/entrypoints/cli.tsx"
},
"scripts": {
"build": "bun scripts/build-bundle.ts",
"build:watch": "bun scripts/build-bundle.ts --watch",
"build:prod": "bun scripts/build-bundle.ts --minify",
"build:web": "bun scripts/build-web.ts",
"build:web:watch": "bun scripts/build-web.ts --watch",
"build:web:prod": "bun scripts/build-web.ts --minify",
"typecheck": "tsc --noEmit",
"lint": "biome check src/",
"lint:fix": "biome check --write src/",
@@ -27,6 +33,13 @@
"@opentelemetry/sdk-logs": "^0.57.0",
"@opentelemetry/sdk-metrics": "^1.30.0",
"@opentelemetry/sdk-trace-base": "^1.30.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"auto-bind": "^5.0.1",
"axios": "^1.7.0",
"chalk": "^5.4.0",
"chokidar": "^4.0.0",
@@ -35,13 +48,18 @@
"diff": "^7.0.0",
"execa": "^9.5.0",
"figures": "^6.1.0",
"fuse.js": "^7.0.0",
"highlight.js": "^11.11.0",
"ignore": "^6.0.0",
"lodash-es": "^4.17.21",
"marked": "^15.0.0",
"node-pty": "^1.1.0",
"p-map": "^7.0.0",
"picomatch": "^4.0.0",
"proper-lockfile": "^4.1.2",
"qrcode": "^1.5.0",
"react": "^19.0.0",
"react-reconciler": "^0.31.0",
"semver": "^7.6.0",
"stack-utils": "^2.0.6",
"strip-ansi": "^7.1.0",
@@ -49,6 +67,7 @@
"tree-kill": "^1.2.2",
"type-fest": "^4.30.0",
"undici": "^7.3.0",
"usehooks-ts": "^3.1.0",
"wrap-ansi": "^9.0.0",
"ws": "^8.18.0",
"yaml": "^2.6.0",
@@ -65,6 +84,7 @@
"@types/semver": "^7.5.8",
"@types/stack-utils": "^2.0.3",
"@types/ws": "^8.5.0",
"esbuild": "^0.25.0",
"typescript": "^5.7.0"
},
"engines": {
@@ -72,5 +92,3 @@
},
"packageManager": "bun@1.1.0"
}

View File

@@ -43,3 +43,43 @@ Currently `npm run build` (which runs `tsc`) fails with TypeScript errors.
1. `cd mcp-server && npm run build` succeeds with zero errors
2. `ls mcp-server/dist/` shows compiled `.js` files
3. `node mcp-server/dist/index.js` starts without immediate crash
---
## Fix: GitHub Push Protection Blocked Push
`git push origin main` is rejected because commits `5e99ad6` and `7adaa5d` contain a **GitHub OAuth token** in `.mcpregistry_github_token`. The file was deleted from disk but still exists in git history. You must rewrite history to remove it.
### Step 1: Scrub secrets from history
```bash
git filter-branch --force --index-filter \
'git rm --cached --ignore-unmatch .mcpregistry_github_token .mcpregistry_registry_token mcp-server/.mcpregistry_github_token mcp-server/.mcpregistry_registry_token' \
--prune-empty HEAD~5..HEAD
```
### Step 2: Push
```bash
git push origin main
```
### Alternative: Interactive rebase
```bash
git rebase -i HEAD~5
# Change "pick" to "edit" for commits 5e99ad6 and 7adaa5d
# At each stop, run:
git rm --cached .mcpregistry_github_token .mcpregistry_registry_token 2>/dev/null
git rm --cached mcp-server/.mcpregistry_github_token mcp-server/.mcpregistry_registry_token 2>/dev/null
git commit --amend --no-edit
git rebase --continue
```
### Step 3: Prevent future leaks
```bash
echo ".mcpregistry_github_token" >> .gitignore
echo ".mcpregistry_registry_token" >> .gitignore
git add .gitignore && git commit -m "chore: gitignore token files"
```

197
scripts/build-bundle.ts Normal file
View File

@@ -0,0 +1,197 @@
// scripts/build-bundle.ts
// Usage: bun scripts/build-bundle.ts [--watch] [--minify] [--no-sourcemap]
//
// Production build: bun scripts/build-bundle.ts --minify
// Dev build: bun scripts/build-bundle.ts
// Watch mode: bun scripts/build-bundle.ts --watch
import * as esbuild from 'esbuild'
import { resolve, dirname } from 'path'
import { chmodSync, readFileSync, existsSync } from 'fs'
import { fileURLToPath } from 'url'
// Bun: import.meta.dir — Node 21+: import.meta.dirname — fallback
const __dir: string =
(import.meta as any).dir ??
(import.meta as any).dirname ??
dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dir, '..')
const watch = process.argv.includes('--watch')
const minify = process.argv.includes('--minify')
const noSourcemap = process.argv.includes('--no-sourcemap')
// Read version from package.json for MACRO injection
const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8'))
const version = pkg.version || '0.0.0-dev'
// ── Plugin: resolve bare 'src/' imports (tsconfig baseUrl: ".") ──
// The codebase uses `import ... from 'src/foo/bar.js'` which relies on
// TypeScript's baseUrl resolution. This plugin maps those to real TS files.
const srcResolverPlugin: esbuild.Plugin = {
name: 'src-resolver',
setup(build) {
build.onResolve({ filter: /^src\// }, (args) => {
const basePath = resolve(ROOT, args.path)
// Already exists as-is
if (existsSync(basePath)) {
return { path: basePath }
}
// Strip .js/.jsx and try TypeScript extensions
const withoutExt = basePath.replace(/\.(js|jsx)$/, '')
for (const ext of ['.ts', '.tsx', '.js', '.jsx']) {
const candidate = withoutExt + ext
if (existsSync(candidate)) {
return { path: candidate }
}
}
// Try as directory with index file
const dirPath = basePath.replace(/\.(js|jsx)$/, '')
for (const ext of ['.ts', '.tsx', '.js', '.jsx']) {
const candidate = resolve(dirPath, 'index' + ext)
if (existsSync(candidate)) {
return { path: candidate }
}
}
// Let esbuild handle it (will error if truly missing)
return undefined
})
},
}
const buildOptions: esbuild.BuildOptions = {
entryPoints: [resolve(ROOT, 'src/entrypoints/cli.tsx')],
bundle: true,
platform: 'node',
target: ['node20', 'es2022'],
format: 'esm',
outdir: resolve(ROOT, 'dist'),
outExtension: { '.js': '.mjs' },
// Single-file output — no code splitting for CLI tools
splitting: false,
plugins: [srcResolverPlugin],
// Use tsconfig for baseUrl / paths resolution (complements plugin above)
tsconfig: resolve(ROOT, 'tsconfig.json'),
// Alias bun:bundle to our runtime shim
alias: {
'bun:bundle': resolve(ROOT, 'src/shims/bun-bundle.ts'),
},
// Don't bundle node built-ins or problematic native packages
external: [
// Node built-ins (with and without node: prefix)
'fs', 'path', 'os', 'crypto', 'child_process', 'http', 'https',
'net', 'tls', 'url', 'util', 'stream', 'events', 'buffer',
'querystring', 'readline', 'zlib', 'assert', 'tty', 'worker_threads',
'perf_hooks', 'async_hooks', 'dns', 'dgram', 'cluster',
'string_decoder', 'module', 'vm', 'constants', 'domain',
'console', 'process', 'v8', 'inspector',
'node:*',
// Native addons that can't be bundled
'fsevents',
'sharp',
'image-processor-napi',
// Anthropic-internal packages (not published externally)
'@anthropic-ai/sandbox-runtime',
'@anthropic-ai/claude-agent-sdk',
// Anthropic-internal (@ant/) packages — gated behind USER_TYPE === 'ant'
'@ant/*',
],
jsx: 'automatic',
// Source maps for production debugging (external .map files)
sourcemap: noSourcemap ? false : 'external',
// Minification for production
minify,
// Tree shaking (on by default, explicit for clarity)
treeShaking: true,
// Define replacements — inline constants at build time
// MACRO.* — originally inlined by Bun's bundler at compile time
// process.env.USER_TYPE — eliminates 'ant' (Anthropic-internal) code branches
define: {
'MACRO.VERSION': JSON.stringify(version),
'MACRO.PACKAGE_URL': JSON.stringify('@anthropic-ai/claude-code'),
'MACRO.ISSUES_EXPLAINER': JSON.stringify(
'report issues at https://github.com/anthropics/claude-code/issues'
),
'process.env.USER_TYPE': '"external"',
'process.env.NODE_ENV': minify ? '"production"' : '"development"',
},
// Banner: shebang for direct CLI execution
banner: {
js: '#!/usr/bin/env node\n',
},
// Handle the .js → .ts resolution that the codebase uses
resolveExtensions: ['.tsx', '.ts', '.jsx', '.js', '.json'],
logLevel: 'info',
// Metafile for bundle analysis
metafile: true,
}
async function main() {
if (watch) {
const ctx = await esbuild.context(buildOptions)
await ctx.watch()
console.log('Watching for changes...')
} else {
const startTime = Date.now()
const result = await esbuild.build(buildOptions)
if (result.errors.length > 0) {
console.error('Build failed')
process.exit(1)
}
// Make the output executable
const outPath = resolve(ROOT, 'dist/cli.mjs')
try {
chmodSync(outPath, 0o755)
} catch {
// chmod may fail on some platforms, non-fatal
}
const elapsed = Date.now() - startTime
// Print bundle size info
if (result.metafile) {
const text = await esbuild.analyzeMetafile(result.metafile, { verbose: false })
const outFiles = Object.entries(result.metafile.outputs)
for (const [file, info] of outFiles) {
if (file.endsWith('.mjs')) {
const sizeMB = ((info as { bytes: number }).bytes / 1024 / 1024).toFixed(2)
console.log(`\n ${file}: ${sizeMB} MB`)
}
}
console.log(`\nBuild complete in ${elapsed}ms → dist/`)
// Write metafile for further analysis
const { writeFileSync } = await import('fs')
writeFileSync(
resolve(ROOT, 'dist/meta.json'),
JSON.stringify(result.metafile),
)
console.log(' Metafile written to dist/meta.json')
}
}
}
main().catch(err => {
console.error(err)
process.exit(1)
})

58
scripts/build-web.ts Normal file
View File

@@ -0,0 +1,58 @@
// scripts/build-web.ts
// Bundles the browser-side terminal frontend.
//
// Usage:
// bun scripts/build-web.ts # dev build
// bun scripts/build-web.ts --watch # watch mode
// bun scripts/build-web.ts --minify # production (minified)
import * as esbuild from 'esbuild'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dir: string =
(import.meta as any).dir ??
(import.meta as any).dirname ??
dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dir, '..')
const ENTRY = resolve(ROOT, 'src/server/web/terminal.ts')
const OUT_DIR = resolve(ROOT, 'src/server/web/public')
const watch = process.argv.includes('--watch')
const minify = process.argv.includes('--minify')
const buildOptions: esbuild.BuildOptions = {
entryPoints: [ENTRY],
bundle: true,
platform: 'browser',
target: ['es2020', 'chrome90', 'firefox90', 'safari14'],
format: 'esm',
outdir: OUT_DIR,
// CSS imported from JS is auto-emitted alongside the JS output
loader: { '.css': 'css' },
minify,
sourcemap: minify ? false : 'inline',
tsconfig: resolve(ROOT, 'src/server/web/tsconfig.json'),
logLevel: 'info',
}
async function main() {
if (watch) {
const ctx = await esbuild.context(buildOptions)
await ctx.watch()
console.log('Watching src/server/web/terminal.ts...')
} else {
const start = Date.now()
const result = await esbuild.build(buildOptions)
if (result.errors.length > 0) {
process.exit(1)
}
console.log(`Web build complete in ${Date.now() - start}ms → ${OUT_DIR}`)
}
}
main().catch(err => {
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,18 @@
// scripts/bun-plugin-shims.ts
// Bun preload plugin — intercepts `bun:bundle` imports at runtime
// and resolves them to our local shim so the CLI can run without
// the production Bun bundler pass.
import { plugin } from 'bun'
import { resolve } from 'path'
plugin({
name: 'bun-bundle-shim',
setup(build) {
const shimPath = resolve(import.meta.dir, '../src/shims/bun-bundle.ts')
build.onResolve({ filter: /^bun:bundle$/ }, () => ({
path: shimPath,
}))
},
})

49
scripts/ci-build.sh Normal file
View File

@@ -0,0 +1,49 @@
#!/bin/bash
# ─────────────────────────────────────────────────────────────
# ci-build.sh — CI/CD build pipeline
# ─────────────────────────────────────────────────────────────
# Runs the full build pipeline: install, typecheck, lint, build,
# and verify the output. Intended for CI environments.
#
# Usage:
# ./scripts/ci-build.sh
# ─────────────────────────────────────────────────────────────
set -euo pipefail
echo "=== Installing dependencies ==="
bun install
echo "=== Type checking ==="
bun run typecheck
echo "=== Linting ==="
bun run lint
echo "=== Building production bundle ==="
bun run build:prod
echo "=== Verifying build output ==="
# Check that the bundle was produced
if [ ! -f dist/cli.mjs ]; then
echo "ERROR: dist/cli.mjs not found"
exit 1
fi
# Print bundle size
SIZE=$(ls -lh dist/cli.mjs | awk '{print $5}')
echo " Bundle size: $SIZE"
# Verify the bundle runs with Node.js
if command -v node &>/dev/null; then
VERSION=$(node dist/cli.mjs --version 2>&1 || true)
echo " node dist/cli.mjs --version → $VERSION"
fi
# Verify the bundle runs with Bun
if command -v bun &>/dev/null; then
VERSION=$(bun dist/cli.mjs --version 2>&1 || true)
echo " bun dist/cli.mjs --version → $VERSION"
fi
echo "=== Done ==="

15
scripts/dev.ts Normal file
View File

@@ -0,0 +1,15 @@
// scripts/dev.ts
// Development launcher — runs the CLI directly via Bun's TS runtime.
//
// Usage:
// bun scripts/dev.ts [args...]
// bun run dev [args...]
//
// The bun:bundle shim is loaded automatically via bunfig.toml preload.
// Bun automatically reads .env files from the project root.
// Load MACRO global (version, package url, etc.) before any app code
import '../src/shims/macro.js'
// Launch the CLI entrypoint
await import('../src/entrypoints/cli.js')

91
scripts/package-npm.ts Normal file
View File

@@ -0,0 +1,91 @@
// scripts/package-npm.ts
// Generate a publishable npm package in dist/npm/
//
// Usage: bun scripts/package-npm.ts
//
// Prerequisites: run `bun run build:prod` first to generate dist/cli.mjs
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, chmodSync } from 'fs'
import { resolve } from 'path'
// Bun: import.meta.dir — Node 21+: import.meta.dirname — fallback
const __dir: string =
(import.meta as ImportMeta & { dir?: string; dirname?: string }).dir ??
(import.meta as ImportMeta & { dir?: string; dirname?: string }).dirname ??
new URL('.', import.meta.url).pathname
const ROOT = resolve(__dir, '..')
const DIST = resolve(ROOT, 'dist')
const NPM_DIR = resolve(DIST, 'npm')
const CLI_BUNDLE = resolve(DIST, 'cli.mjs')
function main() {
// Verify the bundle exists
if (!existsSync(CLI_BUNDLE)) {
console.error('Error: dist/cli.mjs not found. Run `bun run build:prod` first.')
process.exit(1)
}
// Read source package.json
const srcPkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8'))
// Create npm output directory
mkdirSync(NPM_DIR, { recursive: true })
// Copy the bundled CLI
copyFileSync(CLI_BUNDLE, resolve(NPM_DIR, 'cli.mjs'))
chmodSync(resolve(NPM_DIR, 'cli.mjs'), 0o755)
// Copy source map if it exists
const sourceMap = resolve(DIST, 'cli.mjs.map')
if (existsSync(sourceMap)) {
copyFileSync(sourceMap, resolve(NPM_DIR, 'cli.mjs.map'))
}
// Generate a publishable package.json
const npmPkg = {
name: srcPkg.name || '@anthropic-ai/claude-code',
version: srcPkg.version || '0.0.0',
description: srcPkg.description || 'Anthropic Claude Code CLI',
license: 'MIT',
type: 'module',
main: './cli.mjs',
bin: {
claude: './cli.mjs',
},
engines: {
node: '>=20.0.0',
},
os: ['darwin', 'linux', 'win32'],
files: [
'cli.mjs',
'cli.mjs.map',
'README.md',
],
}
writeFileSync(
resolve(NPM_DIR, 'package.json'),
JSON.stringify(npmPkg, null, 2) + '\n',
)
// Copy README if it exists
const readme = resolve(ROOT, 'README.md')
if (existsSync(readme)) {
copyFileSync(readme, resolve(NPM_DIR, 'README.md'))
}
// Summary
const bundleSize = readFileSync(CLI_BUNDLE).byteLength
const sizeMB = (bundleSize / 1024 / 1024).toFixed(2)
console.log('npm package generated in dist/npm/')
console.log(` package: ${npmPkg.name}@${npmPkg.version}`)
console.log(` bundle: cli.mjs (${sizeMB} MB)`)
console.log(` bin: claude → ./cli.mjs`)
console.log('')
console.log('To publish:')
console.log(' cd dist/npm && npm publish')
}
main()

26
scripts/test-auth.ts Normal file
View File

@@ -0,0 +1,26 @@
// scripts/test-auth.ts
// Quick test that the API key is configured and can reach Anthropic
// Usage: bun scripts/test-auth.ts
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
})
async function main() {
try {
const msg = await client.messages.create({
model: process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-20250514',
max_tokens: 50,
messages: [{ role: 'user', content: 'Say "hello" and nothing else.' }],
})
console.log('✅ API connection successful!')
console.log('Response:', msg.content[0].type === 'text' ? msg.content[0].text : msg.content[0])
} catch (err: any) {
console.error('❌ API connection failed:', err.message)
process.exit(1)
}
}
main()

64
scripts/test-commands.ts Normal file
View File

@@ -0,0 +1,64 @@
// scripts/test-commands.ts
// Verify all commands load without errors
// Usage: bun scripts/test-commands.ts
//
// The bun:bundle shim is loaded automatically via bunfig.toml preload.
// Load MACRO global before any app code
import '../src/shims/macro.js'
async function main() {
const { getCommands } = await import('../src/commands.js')
const cwd = process.cwd()
const commands = await getCommands(cwd)
console.log(`Loaded ${commands.length} commands:\n`)
// Group commands by type for readability
const byType: Record<string, typeof commands> = {}
for (const cmd of commands) {
const t = cmd.type
if (!byType[t]) byType[t] = []
byType[t]!.push(cmd)
}
for (const [type, cmds] of Object.entries(byType)) {
console.log(` [${type}] (${cmds.length} commands)`)
for (const cmd of cmds) {
const aliases = cmd.aliases?.length ? ` (aliases: ${cmd.aliases.join(', ')})` : ''
const hidden = cmd.isHidden ? ' [hidden]' : ''
const source = cmd.type === 'prompt' ? ` (source: ${cmd.source})` : ''
console.log(` /${cmd.name}${cmd.description || '(no description)'}${aliases}${hidden}${source}`)
}
console.log()
}
// Verify essential commands are present
const essential = ['help', 'config', 'init', 'commit', 'review']
const commandNames = new Set(commands.map(c => c.name))
const missing = essential.filter(n => !commandNames.has(n))
if (missing.length > 0) {
console.error(`❌ Missing essential commands: ${missing.join(', ')}`)
process.exit(1)
}
console.log(`✅ All ${essential.length} essential commands present: ${essential.join(', ')}`)
// Check moved-to-plugin commands
const movedToPlugin = commands.filter(
c => c.type === 'prompt' && c.description && c.name
).filter(c => ['security-review', 'pr-comments'].includes(c.name))
if (movedToPlugin.length > 0) {
console.log(`✅ Moved-to-plugin commands present and loadable: ${movedToPlugin.map(c => c.name).join(', ')}`)
}
console.log(`\n✅ Command system loaded successfully (${commands.length} commands)`)
}
main().catch(err => {
console.error('❌ Command loading failed:', err)
process.exit(1)
})

180
scripts/test-mcp.ts Normal file
View File

@@ -0,0 +1,180 @@
#!/usr/bin/env npx tsx
/**
* scripts/test-mcp.ts
* Test MCP client/server roundtrip using the standalone mcp-server sub-project.
*
* Usage:
* cd mcp-server && npm install && npm run build && cd ..
* npx tsx scripts/test-mcp.ts
*
* What it does:
* 1. Spawns mcp-server/dist/index.js as a child process (stdio transport)
* 2. Creates an MCP client using @modelcontextprotocol/sdk
* 3. Connects client to server
* 4. Lists available tools
* 5. Calls list_tools and read_source_file tools
* 6. Lists resources and reads one
* 7. Prints results and exits
*/
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PROJECT_ROOT = resolve(__dirname, "..");
// ── Helpers ───────────────────────────────────────────────────────────────
function section(title: string) {
console.log(`\n${"─".repeat(60)}`);
console.log(` ${title}`);
console.log(`${"─".repeat(60)}`);
}
function jsonPretty(obj: unknown): string {
return JSON.stringify(obj, null, 2);
}
// ── Main ──────────────────────────────────────────────────────────────────
async function main() {
const serverScript = resolve(PROJECT_ROOT, "mcp-server", "dist", "index.js");
const srcRoot = resolve(PROJECT_ROOT, "src");
section("1. Spawning MCP server (stdio transport)");
console.log(` Server: ${serverScript}`);
console.log(` SRC_ROOT: ${srcRoot}`);
const transport = new StdioClientTransport({
command: "node",
args: [serverScript],
env: {
...process.env,
CLAUDE_CODE_SRC_ROOT: srcRoot,
} as Record<string, string>,
stderr: "pipe",
});
// Log stderr from the server process
if (transport.stderr) {
transport.stderr.on("data", (data: Buffer) => {
const msg = data.toString().trim();
if (msg) console.log(` [server stderr] ${msg}`);
});
}
section("2. Creating MCP client");
const client = new Client(
{
name: "test-mcp-client",
version: "1.0.0",
},
{
capabilities: {},
}
);
section("3. Connecting client → server");
await client.connect(transport);
console.log(" ✓ Connected successfully");
// ── List Tools ──────────────────────────────────────────────────────────
section("4. Listing available tools");
const toolsResult = await client.listTools();
console.log(` Found ${toolsResult.tools.length} tool(s):`);
for (const tool of toolsResult.tools) {
console.log(`${tool.name}${tool.description?.slice(0, 80)}`);
}
// ── Call list_tools ─────────────────────────────────────────────────────
section("5. Calling tool: list_tools");
const listToolsResult = await client.callTool({
name: "list_tools",
arguments: {},
});
const listToolsContent = listToolsResult.content as Array<{
type: string;
text: string;
}>;
const listToolsText = listToolsContent
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
// Show first 500 chars
console.log(
` Result (first 500 chars):\n${listToolsText.slice(0, 500)}${listToolsText.length > 500 ? "\n …(truncated)" : ""}`
);
// ── Call read_source_file ───────────────────────────────────────────────
section("6. Calling tool: read_source_file (path: 'main.tsx', lines 1-20)");
const readResult = await client.callTool({
name: "read_source_file",
arguments: { path: "main.tsx", startLine: 1, endLine: 20 },
});
const readContent = readResult.content as Array<{
type: string;
text: string;
}>;
const readText = readContent
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
console.log(` Result:\n${readText.slice(0, 600)}`);
// ── List Resources ──────────────────────────────────────────────────────
section("7. Listing resources");
try {
const resourcesResult = await client.listResources();
console.log(` Found ${resourcesResult.resources.length} resource(s):`);
for (const res of resourcesResult.resources.slice(0, 10)) {
console.log(`${res.name} (${res.uri})`);
}
if (resourcesResult.resources.length > 10) {
console.log(
` …and ${resourcesResult.resources.length - 10} more`
);
}
// Read the first resource
if (resourcesResult.resources.length > 0) {
const firstRes = resourcesResult.resources[0]!;
section(`8. Reading resource: ${firstRes.name}`);
const resContent = await client.readResource({ uri: firstRes.uri });
const resText = resContent.contents
.filter((c): c is { uri: string; text: string; mimeType?: string } => "text" in c)
.map((c) => c.text)
.join("\n");
console.log(
` Content (first 400 chars):\n${resText.slice(0, 400)}${resText.length > 400 ? "\n …(truncated)" : ""}`
);
}
} catch (err) {
console.log(` Resources not supported or error: ${err}`);
}
// ── List Prompts ────────────────────────────────────────────────────────
section("9. Listing prompts");
try {
const promptsResult = await client.listPrompts();
console.log(` Found ${promptsResult.prompts.length} prompt(s):`);
for (const p of promptsResult.prompts) {
console.log(`${p.name}${p.description?.slice(0, 80)}`);
}
} catch (err) {
console.log(` Prompts not supported or error: ${err}`);
}
// ── Cleanup ─────────────────────────────────────────────────────────────
section("Done ✓");
console.log(" All tests passed. Closing connection.");
await client.close();
process.exit(0);
}
main().catch((err) => {
console.error("\n✗ Test failed:", err);
process.exit(1);
});

233
scripts/test-services.ts Normal file
View File

@@ -0,0 +1,233 @@
// scripts/test-services.ts
// Test that all services initialize without crashing
// Usage: bun scripts/test-services.ts
import '../src/shims/preload.js'
// Ensure we don't accidentally talk to real servers
process.env.NODE_ENV = process.env.NODE_ENV || 'test'
type TestResult = { name: string; status: 'pass' | 'fail' | 'skip'; detail?: string }
const results: TestResult[] = []
function pass(name: string, detail?: string) {
results.push({ name, status: 'pass', detail })
console.log(`${name}${detail ? `${detail}` : ''}`)
}
function fail(name: string, detail: string) {
results.push({ name, status: 'fail', detail })
console.log(`${name}${detail}`)
}
function skip(name: string, detail: string) {
results.push({ name, status: 'skip', detail })
console.log(` ⏭️ ${name}${detail}`)
}
async function testGrowthBook() {
console.log('\n--- GrowthBook (Feature Flags) ---')
try {
const gb = await import('../src/services/analytics/growthbook.js')
// Test cached feature value returns default when GrowthBook is unavailable
const boolResult = gb.getFeatureValue_CACHED_MAY_BE_STALE('nonexistent_feature', false)
if (boolResult === false) {
pass('getFeatureValue_CACHED_MAY_BE_STALE (bool)', 'returns default false')
} else {
fail('getFeatureValue_CACHED_MAY_BE_STALE (bool)', `expected false, got ${boolResult}`)
}
const strResult = gb.getFeatureValue_CACHED_MAY_BE_STALE('nonexistent_str', 'default_val')
if (strResult === 'default_val') {
pass('getFeatureValue_CACHED_MAY_BE_STALE (str)', 'returns default string')
} else {
fail('getFeatureValue_CACHED_MAY_BE_STALE (str)', `expected "default_val", got "${strResult}"`)
}
// Test Statsig gate check returns false
const gateResult = gb.checkStatsigFeatureGate_CACHED_MAY_BE_STALE('nonexistent_gate')
if (gateResult === false) {
pass('checkStatsigFeatureGate_CACHED_MAY_BE_STALE', 'returns false for unknown gate')
} else {
fail('checkStatsigFeatureGate_CACHED_MAY_BE_STALE', `expected false, got ${gateResult}`)
}
} catch (err: any) {
fail('GrowthBook import', err.message)
}
}
async function testAnalyticsSink() {
console.log('\n--- Analytics Sink ---')
try {
const analytics = await import('../src/services/analytics/index.js')
// logEvent should queue without crashing when no sink is attached
analytics.logEvent('test_event', { test_key: 1 })
pass('logEvent (no sink)', 'queues without crash')
await analytics.logEventAsync('test_async_event', { test_key: 2 })
pass('logEventAsync (no sink)', 'queues without crash')
} catch (err: any) {
fail('Analytics sink', err.message)
}
}
async function testPolicyLimits() {
console.log('\n--- Policy Limits ---')
try {
const pl = await import('../src/services/policyLimits/index.js')
// isPolicyAllowed should return true (fail open) when no restrictions loaded
const result = pl.isPolicyAllowed('allow_remote_sessions')
if (result === true) {
pass('isPolicyAllowed (no cache)', 'fails open — returns true')
} else {
fail('isPolicyAllowed (no cache)', `expected true (fail open), got ${result}`)
}
// isPolicyLimitsEligible should return false without valid auth
const eligible = pl.isPolicyLimitsEligible()
pass('isPolicyLimitsEligible', `returns ${eligible} (expected false in test env)`)
} catch (err: any) {
fail('Policy limits', err.message)
}
}
async function testRemoteManagedSettings() {
console.log('\n--- Remote Managed Settings ---')
try {
const rms = await import('../src/services/remoteManagedSettings/index.js')
// isEligibleForRemoteManagedSettings should return false without auth
const eligible = rms.isEligibleForRemoteManagedSettings()
pass('isEligibleForRemoteManagedSettings', `returns ${eligible} (expected false in test env)`)
// waitForRemoteManagedSettingsToLoad should resolve immediately if not eligible
await rms.waitForRemoteManagedSettingsToLoad()
pass('waitForRemoteManagedSettingsToLoad', 'resolves immediately when not eligible')
} catch (err: any) {
fail('Remote managed settings', err.message)
}
}
async function testBootstrapData() {
console.log('\n--- Bootstrap Data ---')
try {
const bootstrap = await import('../src/services/api/bootstrap.js')
// fetchBootstrapData should not crash — just skip when no auth
await bootstrap.fetchBootstrapData()
pass('fetchBootstrapData', 'completes without crash (skips when no auth)')
} catch (err: any) {
// fetchBootstrapData catches its own errors, so this means an import-level issue
fail('Bootstrap data', err.message)
}
}
async function testSessionMemoryUtils() {
console.log('\n--- Session Memory ---')
try {
const smUtils = await import('../src/services/SessionMemory/sessionMemoryUtils.js')
// Default config should be sensible
const config = smUtils.DEFAULT_SESSION_MEMORY_CONFIG
if (config.minimumMessageTokensToInit > 0 && config.minimumTokensBetweenUpdate > 0) {
pass('DEFAULT_SESSION_MEMORY_CONFIG', `init=${config.minimumMessageTokensToInit} tokens, update=${config.minimumTokensBetweenUpdate} tokens`)
} else {
fail('DEFAULT_SESSION_MEMORY_CONFIG', 'unexpected config values')
}
// getLastSummarizedMessageId should return undefined initially
const lastId = smUtils.getLastSummarizedMessageId()
if (lastId === undefined) {
pass('getLastSummarizedMessageId', 'returns undefined initially')
} else {
fail('getLastSummarizedMessageId', `expected undefined, got ${lastId}`)
}
} catch (err: any) {
fail('Session memory utils', err.message)
}
}
async function testCostTracker() {
console.log('\n--- Cost Tracking ---')
try {
const ct = await import('../src/cost-tracker.js')
// Total cost should start at 0
const cost = ct.getTotalCost()
if (cost === 0) {
pass('getTotalCost', 'starts at $0.00')
} else {
pass('getTotalCost', `current: $${cost.toFixed(4)} (non-zero means restored session)`)
}
// Duration should be available
const duration = ct.getTotalDuration()
pass('getTotalDuration', `${duration}ms`)
// Token counters should be available
const inputTokens = ct.getTotalInputTokens()
const outputTokens = ct.getTotalOutputTokens()
pass('Token counters', `input=${inputTokens}, output=${outputTokens}`)
// Lines changed
const added = ct.getTotalLinesAdded()
const removed = ct.getTotalLinesRemoved()
pass('Lines changed', `+${added} -${removed}`)
} catch (err: any) {
fail('Cost tracker', err.message)
}
}
async function testInit() {
console.log('\n--- Init (entrypoint) ---')
try {
const { init } = await import('../src/entrypoints/init.js')
await init()
pass('init()', 'completed successfully')
} catch (err: any) {
fail('init()', err.message)
}
}
async function main() {
console.log('=== Services Layer Smoke Test ===')
console.log(`Environment: NODE_ENV=${process.env.NODE_ENV}`)
console.log(`Auth: ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY ? '(set)' : '(not set)'}`)
// Test individual services first (order: least-dependent → most-dependent)
await testAnalyticsSink()
await testGrowthBook()
await testPolicyLimits()
await testRemoteManagedSettings()
await testBootstrapData()
await testSessionMemoryUtils()
await testCostTracker()
// Then test the full init sequence
await testInit()
// Summary
console.log('\n=== Summary ===')
const passed = results.filter(r => r.status === 'pass').length
const failed = results.filter(r => r.status === 'fail').length
const skipped = results.filter(r => r.status === 'skip').length
console.log(` ${passed} passed, ${failed} failed, ${skipped} skipped`)
if (failed > 0) {
console.log('\nFailed tests:')
for (const r of results.filter(r => r.status === 'fail')) {
console.log(`${r.name}: ${r.detail}`)
}
process.exit(1)
}
console.log('\n✅ All services handle graceful degradation correctly')
}
main().catch(err => {
console.error('Fatal error in smoke test:', err)
process.exit(1)
})

15
scripts/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["node"]
},
"include": ["./**/*.ts", "./types.d.ts"]
}

86
scripts/types.d.ts vendored Normal file
View File

@@ -0,0 +1,86 @@
// Local type declarations for scripts/ — avoids depending on installed packages
// for type checking in build scripts.
// ── esbuild (minimal surface used by build-bundle.ts) ──
declare module 'esbuild' {
export interface Plugin {
name: string
setup(build: PluginBuild): void
}
export interface PluginBuild {
onResolve(
options: { filter: RegExp },
callback: (args: OnResolveArgs) => OnResolveResult | undefined | null,
): void
}
export interface OnResolveArgs {
path: string
importer: string
namespace: string
resolveDir: string
kind: string
pluginData: unknown
}
export interface OnResolveResult {
path?: string
external?: boolean
namespace?: string
pluginData?: unknown
}
export interface BuildOptions {
entryPoints?: string[]
bundle?: boolean
platform?: string
target?: string[]
format?: string
outdir?: string
outExtension?: Record<string, string>
splitting?: boolean
plugins?: Plugin[]
tsconfig?: string
alias?: Record<string, string>
external?: string[]
jsx?: string
sourcemap?: boolean | string
minify?: boolean
treeShaking?: boolean
define?: Record<string, string>
banner?: Record<string, string>
resolveExtensions?: string[]
logLevel?: string
metafile?: boolean
[key: string]: unknown
}
export interface Metafile {
inputs: Record<string, { bytes: number; imports: unknown[] }>
outputs: Record<string, { bytes: number; inputs: unknown[]; exports: string[] }>
}
export interface BuildResult {
errors: { text: string }[]
warnings: { text: string }[]
metafile?: Metafile
}
export interface BuildContext {
watch(): Promise<void>
serve(options?: unknown): Promise<unknown>
rebuild(): Promise<BuildResult>
dispose(): Promise<void>
}
export function build(options: BuildOptions): Promise<BuildResult>
export function context(options: BuildOptions): Promise<BuildContext>
export function analyzeMetafile(metafile: Metafile, options?: { verbose?: boolean }): Promise<string>
}
// ── Bun's ImportMeta extensions ──
interface ImportMeta {
dir: string
dirname: string
}

View File

@@ -1,4 +1,4 @@
{
cl{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.nirholas/claude-code-explorer-mcp",
"title": "Claude Code Explorer MCP",
@@ -8,5 +8,17 @@
"source": "github",
"subfolder": "mcp-server"
},
"version": "1.1.0"
"version": "1.1.0",
"packages": [
{
"registryType": "npm",
"registryBaseUrl": "https://registry.npmjs.org",
"identifier": "claude-code-explorer-mcp",
"version": "1.1.0",
"transport": {
"type": "stdio"
},
"runtimeHint": "node"
}
]
}

66
src/bridge/stub.ts Normal file
View File

@@ -0,0 +1,66 @@
/**
* Bridge stub — no-op implementations for when BRIDGE_MODE is disabled.
*
* The bridge files themselves are safe to import even when bridge is off
* (no side effects at import time), and all call sites guard execution
* with `feature('BRIDGE_MODE')` checks. This stub exists as a safety net
* for any future code path that might reference bridge functionality
* outside the feature gate.
*
* Usage:
* import { isBridgeAvailable, noopBridgeHandle } from './stub.js'
*/
import type { ReplBridgeHandle } from './replBridge.js'
/** Returns false — bridge is not available in this build/configuration. */
export function isBridgeAvailable(): false {
return false
}
/**
* A no-op ReplBridgeHandle that silently discards all messages.
* Use this when code expects a handle but bridge is disabled.
*/
export const noopBridgeHandle: ReplBridgeHandle = {
bridgeSessionId: '',
environmentId: '',
sessionIngressUrl: '',
writeMessages() {},
writeSdkMessages() {},
sendControlRequest() {},
sendControlResponse() {},
sendControlCancelRequest() {},
sendResult() {},
async teardown() {},
}
/**
* No-op bridge logger that silently drops all output.
*/
export const noopBridgeLogger = {
printBanner() {},
logSessionStart() {},
logSessionComplete() {},
logSessionFailed() {},
logStatus() {},
logVerbose() {},
logError() {},
logReconnected() {},
updateIdleStatus() {},
updateReconnectingStatus() {},
updateSessionStatus() {},
clearStatus() {},
setRepoInfo() {},
setDebugLogPath() {},
setAttached() {},
updateFailedStatus() {},
toggleQr() {},
updateSessionCount() {},
setSpawnModeDisplay() {},
addSession() {},
updateSessionActivity() {},
setSessionTitle() {},
removeSession() {},
refreshDisplay() {},
}

View File

@@ -6,6 +6,7 @@ const x402 = {
aliases: ['wallet', 'pay'],
description: 'Configure x402 crypto payments (USDC on Base)',
argumentHint: '[setup|status|enable|disable|set-limit|remove]',
supportsNonInteractive: true,
load: () => import('./x402.js'),
} satisfies Command

View File

@@ -174,7 +174,7 @@ function handleRemove(): string {
return 'Wallet removed and x402 payments disabled.\nPrivate key has been deleted from config.'
}
export const call: LocalCommandCall = async (_onDone, _context, args) => {
export const call: LocalCommandCall = async (args) => {
const subcommand = (args ?? '').trim().split(/\s+/)[0]?.toLowerCase()
let value: string

View File

@@ -0,0 +1,22 @@
/**
* QuerySource identifies where a query originated from.
* Used for analytics, retry logic, and cache control decisions.
*/
export type QuerySource =
| 'repl_main_thread'
| 'sdk'
| 'compact'
| 'side_question'
| 'agent'
| 'agent:custom'
| 'agent:explore'
| 'agent:plan'
| 'tool_use_summary'
| 'advisor'
| 'hook'
| 'session_memory'
| 'magic_docs'
| 'skill_search'
| 'classifier'
| 'bridge'
| (string & {}) // Allow other string values for extensibility

37
src/query/transitions.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Transition types for the query loop.
*
* Terminal: why the loop exited (returned).
* Continue: why the loop continued to the next iteration (not returned).
*/
/** Terminal transition — the query loop returned. */
export type Terminal = {
reason:
| 'completed'
| 'blocking_limit'
| 'image_error'
| 'model_error'
| 'aborted_streaming'
| 'aborted_tools'
| 'prompt_too_long'
| 'stop_hook_prevented'
| 'hook_stopped'
| 'max_turns'
| (string & {})
error?: unknown
}
/** Continue transition — the loop will iterate again. */
export type Continue = {
reason:
| 'tool_use'
| 'reactive_compact_retry'
| 'max_output_tokens_recovery'
| 'max_output_tokens_escalate'
| 'collapse_drain_retry'
| 'stop_hook_blocking'
| 'token_budget_continuation'
| 'queued_command'
| (string & {})
}

View File

@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { describe, it, afterEach } from "node:test";
import type { IncomingMessage } from "node:http";
import { ConnectionRateLimiter, validateAuthToken } from "../auth.js";
function mockReq(url: string, host = "localhost:3000"): IncomingMessage {
return { url, headers: { host } } as unknown as IncomingMessage;
}
describe("validateAuthToken", () => {
const originalEnv = process.env.AUTH_TOKEN;
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.AUTH_TOKEN;
} else {
process.env.AUTH_TOKEN = originalEnv;
}
});
it("allows all connections when AUTH_TOKEN is not set", () => {
delete process.env.AUTH_TOKEN;
assert.equal(validateAuthToken(mockReq("/ws")), true);
});
it("rejects connections without token when AUTH_TOKEN is set", () => {
process.env.AUTH_TOKEN = "secret123";
assert.equal(validateAuthToken(mockReq("/ws")), false);
});
it("rejects connections with wrong token", () => {
process.env.AUTH_TOKEN = "secret123";
assert.equal(validateAuthToken(mockReq("/ws?token=wrong")), false);
});
it("accepts connections with correct token", () => {
process.env.AUTH_TOKEN = "secret123";
assert.equal(validateAuthToken(mockReq("/ws?token=secret123")), true);
});
});
describe("ConnectionRateLimiter", () => {
it("allows connections under the limit", () => {
const limiter = new ConnectionRateLimiter(3, 60_000);
assert.equal(limiter.allow("1.2.3.4"), true);
assert.equal(limiter.allow("1.2.3.4"), true);
assert.equal(limiter.allow("1.2.3.4"), true);
});
it("blocks connections over the limit", () => {
const limiter = new ConnectionRateLimiter(2, 60_000);
assert.equal(limiter.allow("1.2.3.4"), true);
assert.equal(limiter.allow("1.2.3.4"), true);
assert.equal(limiter.allow("1.2.3.4"), false);
});
it("tracks IPs independently", () => {
const limiter = new ConnectionRateLimiter(1, 60_000);
assert.equal(limiter.allow("1.2.3.4"), true);
assert.equal(limiter.allow("5.6.7.8"), true);
assert.equal(limiter.allow("1.2.3.4"), false);
assert.equal(limiter.allow("5.6.7.8"), false);
});
it("cleans up stale entries", () => {
const limiter = new ConnectionRateLimiter(1, 1); // 1ms window
limiter.allow("1.2.3.4");
// Wait for window to expire
const start = Date.now();
while (Date.now() - start < 5) {
// busy wait 5ms
}
limiter.cleanup();
assert.equal(limiter.allow("1.2.3.4"), true);
});
});

View File

@@ -0,0 +1,159 @@
import assert from "node:assert/strict";
import { describe, it, mock } from "node:test";
import { EventEmitter } from "node:events";
import { SessionManager } from "../session-manager.js";
import type { IPty } from "node-pty";
import type { WebSocket } from "ws";
// --- Mock factories ---
function createMockPty(): IPty & { _dataHandler?: (d: string) => void; _exitHandler?: (e: { exitCode: number; signal: number }) => void } {
const mockPty = {
onData(handler: (data: string) => void) {
mockPty._dataHandler = handler;
return { dispose() {} };
},
onExit(handler: (e: { exitCode: number; signal: number }) => void) {
mockPty._exitHandler = handler;
return { dispose() {} };
},
write: mock.fn(),
resize: mock.fn(),
kill: mock.fn(),
pid: 12345,
cols: 80,
rows: 24,
process: "claude",
handleFlowControl: false,
pause: mock.fn(),
resume: mock.fn(),
clear: mock.fn(),
_dataHandler: undefined as ((d: string) => void) | undefined,
_exitHandler: undefined as ((e: { exitCode: number; signal: number }) => void) | undefined,
};
return mockPty as unknown as IPty & { _dataHandler?: (d: string) => void; _exitHandler?: (e: { exitCode: number; signal: number }) => void };
}
function createMockWs(): WebSocket & EventEmitter {
const emitter = new EventEmitter();
const ws = Object.assign(emitter, {
OPEN: 1,
CONNECTING: 0,
readyState: 1,
send: mock.fn(),
close: mock.fn(),
});
return ws as unknown as WebSocket & EventEmitter;
}
describe("SessionManager", () => {
it("creates a session and tracks it", () => {
const mockPty = createMockPty();
const manager = new SessionManager(5, () => mockPty);
const ws = createMockWs();
const session = manager.create(ws);
assert.ok(session);
assert.equal(manager.activeCount, 1);
assert.ok(session.id);
assert.equal(session.ws, ws);
assert.equal(session.pty, mockPty);
});
it("enforces max sessions limit", () => {
const manager = new SessionManager(1, () => createMockPty());
const session1 = manager.create(createMockWs());
assert.ok(session1);
const ws2 = createMockWs();
const session2 = manager.create(ws2);
assert.equal(session2, null);
assert.equal(manager.activeCount, 1);
});
it("forwards PTY data to WebSocket", () => {
const mockPty = createMockPty();
const manager = new SessionManager(5, () => mockPty);
const ws = createMockWs();
manager.create(ws);
// Simulate PTY output
mockPty._dataHandler?.("hello world");
assert.equal((ws.send as ReturnType<typeof mock.fn>).mock.callCount(), 1);
});
it("forwards WebSocket input to PTY", () => {
const mockPty = createMockPty();
const manager = new SessionManager(5, () => mockPty);
const ws = createMockWs();
manager.create(ws);
// Simulate WebSocket input
ws.emit("message", Buffer.from("ls\n"));
assert.equal((mockPty.write as ReturnType<typeof mock.fn>).mock.callCount(), 1);
});
it("handles resize messages", () => {
const mockPty = createMockPty();
const manager = new SessionManager(5, () => mockPty);
const ws = createMockWs();
manager.create(ws);
ws.emit("message", JSON.stringify({ type: "resize", cols: 120, rows: 40 }));
assert.equal((mockPty.resize as ReturnType<typeof mock.fn>).mock.callCount(), 1);
});
it("handles ping messages with pong response", () => {
const mockPty = createMockPty();
const manager = new SessionManager(5, () => mockPty);
const ws = createMockWs();
manager.create(ws);
ws.emit("message", JSON.stringify({ type: "ping" }));
// Should have sent connected + pong
const calls = (ws.send as ReturnType<typeof mock.fn>).mock.calls;
const lastCall = calls[calls.length - 1];
assert.ok(lastCall);
const parsed = JSON.parse(lastCall.arguments[0] as string);
assert.equal(parsed.type, "pong");
});
it("cleans up session on WebSocket close", () => {
const mockPty = createMockPty();
const manager = new SessionManager(5, () => mockPty);
const ws = createMockWs();
manager.create(ws);
assert.equal(manager.activeCount, 1);
ws.emit("close");
assert.equal(manager.activeCount, 0);
});
it("handles PTY spawn failure gracefully", () => {
const manager = new SessionManager(5, () => {
throw new Error("no pty available");
});
const ws = createMockWs();
const session = manager.create(ws);
assert.equal(session, null);
assert.equal((ws.close as ReturnType<typeof mock.fn>).mock.callCount(), 1);
});
it("destroyAll cleans up all sessions", () => {
const manager = new SessionManager(5, () => createMockPty());
manager.create(createMockWs());
manager.create(createMockWs());
assert.equal(manager.activeCount, 2);
manager.destroyAll();
assert.equal(manager.activeCount, 0);
});
});

188
src/server/web/admin.ts Normal file
View File

@@ -0,0 +1,188 @@
import { Router } from "express";
import { join } from "path";
import { readFileSync } from "fs";
import type { Request, Response } from "express";
import type { AuthenticatedRequest } from "./auth/adapter.js";
import type { SessionManager } from "./session-manager.js";
import type { UserStore } from "./user-store.js";
/**
* Admin dashboard routes.
*
* All routes under /admin require the requesting user to have isAdmin = true.
* The caller (pty-server.ts) is responsible for applying the auth middleware
* before mounting this router.
*/
function requireAdmin(req: Request, res: Response, next: () => void): void {
const user = (req as AuthenticatedRequest).user;
if (!user?.isAdmin) {
res.status(403).json({ error: "Forbidden" });
return;
}
next();
}
export function createAdminRouter(
sessionManager: SessionManager,
userStore: UserStore,
): Router {
const router = Router();
// All admin routes require admin role.
router.use(requireAdmin);
// ── Dashboard UI ──────────────────────────────────────────────────────────
router.get("/", (_req, res) => {
try {
const p = join(import.meta.dirname, "public/admin.html");
res.setHeader("Content-Type", "text/html");
res.send(readFileSync(p, "utf8"));
} catch {
res.setHeader("Content-Type", "text/html");
res.send(INLINE_ADMIN_HTML);
}
});
// ── API: all active sessions ──────────────────────────────────────────────
/**
* GET /admin/sessions
* Returns all active sessions across all users.
*/
router.get("/sessions", (_req, res) => {
const sessions = sessionManager.getAllSessions().map((s) => ({
id: s.token,
userId: s.userId,
createdAt: s.created,
ageMs: Date.now() - new Date(s.created).getTime(),
alive: s.alive,
}));
res.json({ sessions });
});
// ── API: all connected users ──────────────────────────────────────────────
/**
* GET /admin/users
* Returns all users that currently have at least one active session.
*/
router.get("/users", (_req, res) => {
res.json({ users: userStore.list() });
});
// ── API: force-kill a session ─────────────────────────────────────────────
/**
* DELETE /admin/sessions/:token
* Force-kills the specified session regardless of which user owns it.
*/
router.delete("/sessions/:token", (req, res) => {
const { token } = req.params;
const session = sessionManager.getSession(token);
if (!session) {
res.status(404).json({ error: "Session not found" });
return;
}
sessionManager.destroySession(token);
res.json({ ok: true, destroyed: token });
});
return router;
}
// ── Inline admin dashboard HTML ───────────────────────────────────────────────
const INLINE_ADMIN_HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Claude Code — Admin</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #0d1117; color: #e6edf3; padding: 2rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
.subtitle { color: #8b949e; font-size: 0.875rem; margin-bottom: 2rem; }
h2 { font-size: 1rem; margin: 1.5rem 0 0.75rem; color: #8b949e; text-transform: uppercase; letter-spacing: .05em; }
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
th { text-align: left; padding: 0.4rem 0.75rem; border-bottom: 1px solid #21262d; color: #8b949e; font-weight: 500; }
td { padding: 0.4rem 0.75rem; border-bottom: 1px solid #161b22; }
tr:hover td { background: #161b22; }
button.kill {
background: #da3633; border: 1px solid #f85149; color: #fff;
padding: 0.2rem 0.6rem; border-radius: 4px; cursor: pointer; font-size: 0.8rem;
}
button.kill:hover { background: #f85149; }
.badge {
display: inline-block; padding: 0.15rem 0.5rem; border-radius: 9999px;
font-size: 0.75rem; background: #21262d; color: #8b949e;
}
.refresh { float: right; background: #21262d; border: 1px solid #30363d; color: #8b949e;
padding: 0.3rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.8rem; }
.refresh:hover { color: #e6edf3; }
#msg { margin-top: 1rem; font-size: 0.875rem; color: #3fb950; min-height: 1.2em; }
</style>
</head>
<body>
<h1>Admin Dashboard</h1>
<p class="subtitle">Claude Code — multi-user session management</p>
<button class="refresh" onclick="load()">&#8635; Refresh</button>
<h2>Connected Users</h2>
<table id="users-table">
<thead><tr><th>User ID</th><th>Email / Name</th><th>Sessions</th><th>First seen</th></tr></thead>
<tbody id="users-body"><tr><td colspan="4">Loading…</td></tr></tbody>
</table>
<h2>Active Sessions</h2>
<table id="sessions-table">
<thead><tr><th>Session ID</th><th>User ID</th><th>Age</th><th>Action</th></tr></thead>
<tbody id="sessions-body"><tr><td colspan="4">Loading…</td></tr></tbody>
</table>
<div id="msg"></div>
<script>
const msg = document.getElementById('msg');
function fmt(ms) {
if (ms < 60000) return Math.round(ms/1000) + 's';
if (ms < 3600000) return Math.round(ms/60000) + 'm';
return Math.round(ms/3600000) + 'h';
}
async function load() {
const [{ users }, { sessions }] = await Promise.all([
fetch('/admin/users').then(r => r.json()),
fetch('/admin/sessions').then(r => r.json()),
]);
const ub = document.getElementById('users-body');
ub.innerHTML = users.length === 0 ? '<tr><td colspan="4">No connected users</td></tr>' :
users.map(u => \`<tr>
<td><code>\${u.id}</code></td>
<td>\${u.email || u.name || '—'}</td>
<td><span class="badge">\${u.sessionCount}</span></td>
<td>\${new Date(u.firstSeenAt).toLocaleTimeString()}</td>
</tr>\`).join('');
const sb = document.getElementById('sessions-body');
sb.innerHTML = sessions.length === 0 ? '<tr><td colspan="4">No active sessions</td></tr>' :
sessions.map(s => \`<tr>
<td><code>\${s.id.slice(0,8)}…</code></td>
<td><code>\${s.userId}</code></td>
<td>\${fmt(s.ageMs)}</td>
<td><button class="kill" onclick="kill('\${s.id}')">Kill</button></td>
</tr>\`).join('');
}
async function kill(id) {
if (!confirm('Kill session ' + id.slice(0,8) + '…?')) return;
const r = await fetch('/admin/sessions/' + id, { method: 'DELETE' });
const j = await r.json();
msg.textContent = j.ok ? 'Session ' + id.slice(0,8) + '… destroyed.' : j.error;
load();
}
load();
setInterval(load, 10000);
</script>
</body>
</html>`;

65
src/server/web/auth.ts Normal file
View File

@@ -0,0 +1,65 @@
import type { IncomingMessage } from "http";
/**
* Validates the auth token from a WebSocket upgrade request.
* If AUTH_TOKEN is not set, all connections are allowed.
*/
export function validateAuthToken(req: IncomingMessage): boolean {
const authToken = process.env.AUTH_TOKEN;
if (!authToken) {
return true;
}
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
const token = url.searchParams.get("token");
return token === authToken;
}
/**
* Simple per-IP rate limiter for new connections.
*/
export class ConnectionRateLimiter {
private attempts = new Map<string, number[]>();
private readonly maxPerWindow: number;
private readonly windowMs: number;
constructor(maxPerWindow = 5, windowMs = 60_000) {
this.maxPerWindow = maxPerWindow;
this.windowMs = windowMs;
}
/**
* Returns true if the connection should be allowed.
*/
allow(ip: string): boolean {
const now = Date.now();
const timestamps = this.attempts.get(ip) ?? [];
// Prune old entries
const recent = timestamps.filter((t) => now - t < this.windowMs);
if (recent.length >= this.maxPerWindow) {
this.attempts.set(ip, recent);
return false;
}
recent.push(now);
this.attempts.set(ip, recent);
return true;
}
/**
* Periodically clean up stale entries.
*/
cleanup(): void {
const now = Date.now();
for (const [ip, timestamps] of this.attempts) {
const recent = timestamps.filter((t) => now - t < this.windowMs);
if (recent.length === 0) {
this.attempts.delete(ip);
} else {
this.attempts.set(ip, recent);
}
}
}
}

View File

@@ -0,0 +1,220 @@
import { createHmac, createCipheriv, createDecipheriv, randomBytes } from "crypto";
import type { IncomingMessage, ServerResponse } from "http";
import type { Application, Request, Response, NextFunction } from "express";
// ── Public types ────────────────────────────────────────────────────────────
export interface AuthUser {
id: string;
email?: string;
name?: string;
isAdmin: boolean;
/** Decrypted Anthropic API key (only present for apikey auth provider) */
apiKey?: string;
}
export interface SessionData {
userId: string;
email?: string;
name?: string;
isAdmin: boolean;
/** AES-256-GCM encrypted Anthropic API key */
encryptedApiKey?: string;
createdAt: number;
expiresAt: number;
}
/** Augmented Express request with authenticated user attached. */
export interface AuthenticatedRequest extends Request {
user: AuthUser;
}
/** Auth adapter — pluggable authentication strategy. */
export interface AuthAdapter {
/**
* Authenticate an IncomingMessage (HTTP or WebSocket upgrade).
* Returns null when the request is unauthenticated.
*/
authenticate(req: IncomingMessage): AuthUser | null;
/**
* Register login/callback/logout routes on the Express app.
* Called once during server startup before any requests arrive.
*/
setupRoutes(app: Application): void;
/**
* Express middleware that rejects unauthenticated requests.
* For browser clients it redirects to /auth/login; for API clients it
* returns 401 JSON.
*/
requireAuth(req: Request, res: Response, next: NextFunction): void;
}
// ── Cookie helpers ──────────────────────────────────────────────────────────
const COOKIE_NAME = "cc_session";
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 h
function parseCookies(header: string): Record<string, string> {
const out: Record<string, string> = {};
for (const part of header.split(";")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
const key = part.slice(0, eq).trim();
const val = part.slice(eq + 1).trim();
if (key) out[key] = decodeURIComponent(val);
}
return out;
}
// ── SessionStore ────────────────────────────────────────────────────────────
/**
* In-memory server-side session store.
*
* Sessions are identified by a random UUID stored in a signed HttpOnly cookie.
* Sensitive values (API keys) are stored encrypted with AES-256-GCM.
*/
export class SessionStore {
private readonly sessions = new Map<string, SessionData>();
/** 32-byte key derived from the session secret. */
private readonly key: Buffer;
constructor(secret: string) {
// Derive a stable 32-byte key so the same secret always produces the
// same key (important if the process restarts while cookies are live).
const hmac = createHmac("sha256", secret);
hmac.update("cc-session-key-v1");
this.key = hmac.digest();
// Purge expired sessions every 5 minutes.
setInterval(() => this.cleanup(), 5 * 60_000).unref();
}
// ── CRUD ──────────────────────────────────────────────────────────────────
create(data: Omit<SessionData, "createdAt" | "expiresAt">): string {
const id = crypto.randomUUID();
this.sessions.set(id, {
...data,
createdAt: Date.now(),
expiresAt: Date.now() + SESSION_TTL_MS,
});
return id;
}
get(id: string): SessionData | undefined {
const s = this.sessions.get(id);
if (!s) return undefined;
if (Date.now() > s.expiresAt) {
this.sessions.delete(id);
return undefined;
}
return s;
}
delete(id: string): void {
this.sessions.delete(id);
}
// ── Cookie signing ────────────────────────────────────────────────────────
sign(id: string): string {
const hmac = createHmac("sha256", this.key);
hmac.update(id);
return `${id}.${hmac.digest("base64url")}`;
}
/**
* Verifies the HMAC and returns the raw session ID, or null on failure.
* Uses constant-time comparison to prevent timing attacks.
*/
unsign(signed: string): string | null {
const dot = signed.lastIndexOf(".");
if (dot === -1) return null;
const id = signed.slice(0, dot);
const provided = signed.slice(dot + 1);
const hmac = createHmac("sha256", this.key);
hmac.update(id);
const expected = hmac.digest("base64url");
if (provided.length !== expected.length) return null;
let diff = 0;
for (let i = 0; i < provided.length; i++) {
diff |= provided.charCodeAt(i) ^ expected.charCodeAt(i);
}
return diff === 0 ? id : null;
}
// ── Request / response helpers ────────────────────────────────────────────
/** Returns the session data for the current request, or null. */
getFromRequest(req: IncomingMessage): SessionData | null {
const cookies = parseCookies(req.headers.cookie ?? "");
const signed = cookies[COOKIE_NAME];
if (!signed) return null;
const id = this.unsign(signed);
if (!id) return null;
return this.get(id) ?? null;
}
/** Returns the raw session ID from the request cookie, or null. */
getIdFromRequest(req: IncomingMessage): string | null {
const cookies = parseCookies(req.headers.cookie ?? "");
const signed = cookies[COOKIE_NAME];
if (!signed) return null;
return this.unsign(signed);
}
setCookie(res: ServerResponse, sessionId: string): void {
const signed = this.sign(sessionId);
const maxAge = Math.floor(SESSION_TTL_MS / 1000);
res.setHeader("Set-Cookie", [
`${COOKIE_NAME}=${encodeURIComponent(signed)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}`,
]);
}
clearCookie(res: ServerResponse): void {
res.setHeader("Set-Cookie", [
`${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`,
]);
}
// ── Encryption ────────────────────────────────────────────────────────────
/** Encrypts a plaintext string with AES-256-GCM for session storage. */
encrypt(plaintext: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
// Layout: iv(12) | tag(16) | ciphertext
return Buffer.concat([iv, tag, ciphertext]).toString("base64url");
}
/** Decrypts a value produced by {@link encrypt}. Returns null on failure. */
decrypt(encoded: string): string | null {
try {
const buf = Buffer.from(encoded, "base64url");
const iv = buf.subarray(0, 12);
const tag = buf.subarray(12, 28);
const ciphertext = buf.subarray(28);
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
decipher.setAuthTag(tag);
return decipher.update(ciphertext).toString("utf8") + decipher.final("utf8");
} catch {
return null;
}
}
// ── Internals ─────────────────────────────────────────────────────────────
private cleanup(): void {
const now = Date.now();
for (const [id, s] of this.sessions) {
if (now > s.expiresAt) this.sessions.delete(id);
}
}
}

View File

@@ -0,0 +1,230 @@
import { createHash } from "crypto";
import { readFileSync } from "fs";
import { join } from "path";
import type { IncomingMessage } from "http";
import type { Application, Request, Response, NextFunction } from "express";
import type { AuthAdapter, AuthUser, AuthenticatedRequest } from "./adapter.js";
import { SessionStore } from "./adapter.js";
/**
* API-key authentication adapter.
*
* Each user provides their own Anthropic API key on the login page.
* The key is stored encrypted in the server-side session and is injected
* as `ANTHROPIC_API_KEY` into every PTY spawned for that user.
* The plaintext key is never sent to the browser after the login form POST.
*
* User identity is derived from the key itself (SHA-256 prefix), so two
* sessions using the same key share the same userId and home directory.
*
* Optional env vars:
* ADMIN_USERS — comma-separated user IDs (SHA-256 prefixes) or API-key
* prefixes that receive the admin role
*/
export class ApiKeyAdapter implements AuthAdapter {
private readonly store: SessionStore;
private readonly adminUsers: ReadonlySet<string>;
constructor(store: SessionStore) {
this.store = store;
this.adminUsers = new Set(
(process.env.ADMIN_USERS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
}
authenticate(req: IncomingMessage): AuthUser | null {
const session = this.store.getFromRequest(req);
if (!session || !session.encryptedApiKey) return null;
const apiKey = this.store.decrypt(session.encryptedApiKey);
if (!apiKey) return null;
return {
id: session.userId,
email: session.email,
name: session.name,
isAdmin:
session.isAdmin ||
this.adminUsers.has(session.userId),
apiKey,
};
}
setupRoutes(app: Application): void {
const loginHtml = this.loadLoginPage();
// GET /auth/login — serve the API key login form
app.get("/auth/login", (_req, res) => {
res.setHeader("Content-Type", "text/html");
res.send(loginHtml);
});
// POST /auth/login — validate key, create encrypted session
app.post(
"/auth/login",
// express.urlencoded is registered in pty-server.ts before setupRoutes
(req: Request, res: Response) => {
const apiKey = (req.body as Record<string, string>)?.api_key?.trim() ?? "";
if (!apiKey.startsWith("sk-ant-")) {
res.setHeader("Content-Type", "text/html");
res.status(400).send(
loginHtml.replace(
"<!--ERROR-->",
`<p class="error">Invalid API key format. Keys must start with <code>sk-ant-</code>.</p>`,
),
);
return;
}
const userId = deriveUserId(apiKey);
const isAdmin = this.adminUsers.has(userId);
const encryptedApiKey = this.store.encrypt(apiKey);
const sessionId = this.store.create({
userId,
isAdmin,
encryptedApiKey,
});
this.store.setCookie(res as unknown as import("http").ServerResponse, sessionId);
const next = (req.query as Record<string, string>)?.next;
res.redirect(next && next.startsWith("/") ? next : "/");
},
);
// POST /auth/logout — destroy session
app.post("/auth/logout", (req, res) => {
const id = this.store.getIdFromRequest(req as unknown as IncomingMessage);
if (id) this.store.delete(id);
this.store.clearCookie(res as unknown as import("http").ServerResponse);
res.redirect("/auth/login");
});
}
requireAuth(req: Request, res: Response, next: NextFunction): void {
const user = this.authenticate(req as unknown as IncomingMessage);
if (!user) {
const accept = req.headers["accept"] ?? "";
if (accept.includes("application/json")) {
res.status(401).json({ error: "Unauthorized" });
} else {
res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
}
return;
}
(req as AuthenticatedRequest).user = user;
next();
}
// ── Internals ─────────────────────────────────────────────────────────────
private loadLoginPage(): string {
// Serve from the public directory at build time; fall back to inline HTML.
try {
const p = join(import.meta.dirname, "../public/login.html");
return readFileSync(p, "utf8");
} catch {
return INLINE_LOGIN_HTML;
}
}
}
/**
* Derives a stable, opaque user ID from an API key.
* Uses the first 16 hex chars of SHA-256(key) — short enough to be readable,
* long enough to be unique.
*/
function deriveUserId(apiKey: string): string {
return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
}
// Fallback inline login page used when public/login.html is not present.
const INLINE_LOGIN_HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Claude Code — Sign In</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #0d1117;
color: #e6edf3;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 400px;
}
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
p.subtitle { color: #8b949e; font-size: 0.875rem; margin-bottom: 1.5rem; }
label { display: block; font-size: 0.875rem; margin-bottom: 0.4rem; color: #8b949e; }
input[type="password"] {
width: 100%;
padding: 0.5rem 0.75rem;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
font-size: 0.9rem;
margin-bottom: 1rem;
}
input[type="password"]:focus {
outline: none;
border-color: #58a6ff;
}
button {
width: 100%;
padding: 0.6rem;
background: #238636;
border: 1px solid #2ea043;
border-radius: 6px;
color: #fff;
font-size: 0.95rem;
cursor: pointer;
}
button:hover { background: #2ea043; }
.error { color: #f85149; font-size: 0.875rem; margin-bottom: 1rem; }
code { background: #21262d; padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.8rem; }
.hint { color: #8b949e; font-size: 0.75rem; margin-top: 1rem; }
.hint a { color: #58a6ff; }
</style>
</head>
<body>
<div class="card">
<h1>Claude Code</h1>
<p class="subtitle">Enter your Anthropic API key to start a session.</p>
<!--ERROR-->
<form method="POST" action="/auth/login">
<label for="api_key">Anthropic API Key</label>
<input
type="password"
id="api_key"
name="api_key"
placeholder="sk-ant-..."
autocomplete="off"
required
autofocus
>
<button type="submit">Sign In</button>
</form>
<p class="hint">
Your key is stored encrypted on the server and never sent to the browser.
Get a key at <a href="https://console.anthropic.com" target="_blank" rel="noopener">console.anthropic.com</a>.
</p>
</div>
</body>
</html>`;

View File

@@ -0,0 +1,276 @@
import { randomBytes } from "crypto";
import { request as nodeRequest } from "https";
import { request as nodeHttpRequest } from "http";
import type { IncomingMessage } from "http";
import type { Application, Request, Response, NextFunction } from "express";
import type { AuthAdapter, AuthUser, AuthenticatedRequest } from "./adapter.js";
import { SessionStore } from "./adapter.js";
interface OIDCDiscovery {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
}
interface TokenResponse {
access_token: string;
id_token?: string;
error?: string;
}
interface UserInfoResponse {
sub: string;
email?: string;
name?: string;
preferred_username?: string;
}
/** Minimal JSON fetch over https/http. */
function fetchJSON<T>(url: string, opts?: { method?: string; body?: string; headers?: Record<string, string> }): Promise<T> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const isHttps = parsed.protocol === "https:";
const req = (isHttps ? nodeRequest : nodeHttpRequest)(
{
hostname: parsed.hostname,
port: parsed.port || (isHttps ? 443 : 80),
path: parsed.pathname + parsed.search,
method: opts?.method ?? "GET",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
...(opts?.headers ?? {}),
},
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (c: Buffer) => chunks.push(c));
res.on("end", () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")) as T);
} catch (e) {
reject(e);
}
});
},
);
req.on("error", reject);
if (opts?.body) req.write(opts.body);
req.end();
});
}
/**
* OAuth2 / OIDC authentication adapter.
*
* Performs a server-side authorization code flow against any OIDC-compliant
* identity provider (Google, GitHub, Okta, Auth0, …).
*
* Required env vars:
* OAUTH_CLIENT_ID — client ID registered with the provider
* OAUTH_CLIENT_SECRET — client secret
* OAUTH_ISSUER — issuer base URL, e.g. https://accounts.google.com
*
* Optional:
* OAUTH_CALLBACK_URL — full redirect URI (default: http://localhost:3000/auth/callback)
* OAUTH_SCOPES — space-separated scopes (default: openid email profile)
* ADMIN_USERS — comma-separated user IDs or emails that get admin role
*/
export class OAuthAdapter implements AuthAdapter {
private readonly store: SessionStore;
private readonly clientId: string;
private readonly clientSecret: string;
private readonly issuer: string;
private readonly callbackUrl: string;
private readonly scopes: string;
private readonly adminUsers: ReadonlySet<string>;
/** Cached OIDC discovery document. */
private discovery: OIDCDiscovery | null = null;
/** In-flight state tokens to prevent CSRF. */
private readonly pendingStates = new Map<string, number>();
constructor(store: SessionStore) {
this.store = store;
this.clientId = process.env.OAUTH_CLIENT_ID ?? "";
this.clientSecret = process.env.OAUTH_CLIENT_SECRET ?? "";
this.issuer = (process.env.OAUTH_ISSUER ?? "").replace(/\/$/, "");
this.callbackUrl =
process.env.OAUTH_CALLBACK_URL ?? "http://localhost:3000/auth/callback";
this.scopes = process.env.OAUTH_SCOPES ?? "openid email profile";
this.adminUsers = new Set(
(process.env.ADMIN_USERS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
// Periodically prune stale state tokens (>10 min old).
setInterval(() => {
const cutoff = Date.now() - 10 * 60_000;
for (const [s, t] of this.pendingStates) {
if (t < cutoff) this.pendingStates.delete(s);
}
}, 5 * 60_000).unref();
}
authenticate(req: IncomingMessage): AuthUser | null {
const session = this.store.getFromRequest(req);
if (!session) return null;
return {
id: session.userId,
email: session.email,
name: session.name,
isAdmin:
session.isAdmin ||
this.adminUsers.has(session.userId) ||
(session.email ? this.adminUsers.has(session.email) : false),
};
}
setupRoutes(app: Application): void {
// GET /auth/login — redirect to the identity provider
app.get("/auth/login", async (_req, res) => {
try {
const disc = await this.getDiscovery();
const state = randomBytes(16).toString("hex");
this.pendingStates.set(state, Date.now());
const authUrl = new URL(disc.authorization_endpoint);
authUrl.searchParams.set("client_id", this.clientId);
authUrl.searchParams.set("redirect_uri", this.callbackUrl);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", this.scopes);
authUrl.searchParams.set("state", state);
// Store state in a short-lived cookie for CSRF validation
res.setHeader("Set-Cookie", [
`oauth_state=${state}; HttpOnly; SameSite=Lax; Path=/auth; Max-Age=600`,
]);
res.redirect(authUrl.toString());
} catch (err) {
console.error("[oauth] Failed to initiate login:", err);
res.status(500).send("Authentication provider unavailable.");
}
});
// GET /auth/callback — exchange code for tokens, create session
app.get("/auth/callback", async (req, res) => {
const { code, state, error } = req.query as Record<string, string | undefined>;
if (error) {
console.warn("[oauth] Provider error:", error);
res.status(400).send(`Provider returned error: ${error}`);
return;
}
if (!code || !state) {
res.status(400).send("Missing code or state.");
return;
}
// Validate state against cookie
const storedState = this.extractStateCookie(req as unknown as IncomingMessage);
if (!storedState || storedState !== state || !this.pendingStates.has(state)) {
res.status(400).send("Invalid state parameter.");
return;
}
this.pendingStates.delete(state);
// Clear state cookie
res.setHeader("Set-Cookie", [
`oauth_state=; HttpOnly; SameSite=Lax; Path=/auth; Max-Age=0`,
]);
try {
const disc = await this.getDiscovery();
const tokens = await this.exchangeCode(disc.token_endpoint, code);
const userInfo = await this.getUserInfo(disc.userinfo_endpoint, tokens.access_token);
const isAdmin =
this.adminUsers.has(userInfo.sub) ||
(userInfo.email ? this.adminUsers.has(userInfo.email) : false);
const sessionId = this.store.create({
userId: userInfo.sub,
email: userInfo.email,
name: userInfo.name ?? userInfo.preferred_username,
isAdmin,
});
this.store.setCookie(res as unknown as import("http").ServerResponse, sessionId);
res.redirect("/");
} catch (err) {
console.error("[oauth] Callback error:", err);
res.status(500).send("Authentication failed. Please try again.");
}
});
// POST /auth/logout — destroy session and redirect to login
app.post("/auth/logout", (req, res) => {
const id = this.store.getIdFromRequest(req as unknown as IncomingMessage);
if (id) this.store.delete(id);
this.store.clearCookie(res as unknown as import("http").ServerResponse);
res.redirect("/auth/login");
});
}
requireAuth(req: Request, res: Response, next: NextFunction): void {
const user = this.authenticate(req as unknown as IncomingMessage);
if (!user) {
const accept = req.headers["accept"] ?? "";
if (accept.includes("application/json")) {
res.status(401).json({ error: "Unauthorized" });
} else {
res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
}
return;
}
(req as AuthenticatedRequest).user = user;
next();
}
// ── OIDC helpers ──────────────────────────────────────────────────────────
private async getDiscovery(): Promise<OIDCDiscovery> {
if (this.discovery) return this.discovery;
const url = `${this.issuer}/.well-known/openid-configuration`;
const doc = await fetchJSON<OIDCDiscovery>(url);
this.discovery = doc;
return doc;
}
private async exchangeCode(tokenEndpoint: string, code: string): Promise<TokenResponse> {
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: this.callbackUrl,
client_id: this.clientId,
client_secret: this.clientSecret,
}).toString();
const result = await fetchJSON<TokenResponse>(tokenEndpoint, {
method: "POST",
body,
});
if (result.error) throw new Error(`Token exchange failed: ${result.error}`);
return result;
}
private async getUserInfo(userinfoEndpoint: string, accessToken: string): Promise<UserInfoResponse> {
return fetchJSON<UserInfoResponse>(userinfoEndpoint, {
headers: { Authorization: `Bearer ${accessToken}` },
});
}
private extractStateCookie(req: IncomingMessage): string | null {
const cookieHeader = req.headers.cookie ?? "";
for (const part of cookieHeader.split(";")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
if (part.slice(0, eq).trim() === "oauth_state") {
return decodeURIComponent(part.slice(eq + 1).trim());
}
}
return null;
}
}

View File

@@ -0,0 +1,81 @@
import type { IncomingMessage } from "http";
import type { Application, Request, Response, NextFunction } from "express";
import type { AuthAdapter, AuthUser, AuthenticatedRequest } from "./adapter.js";
/**
* Token auth — the original single-token mode.
*
* If `AUTH_TOKEN` is set, callers must supply a matching token via the
* `?token=` query parameter (WebSocket) or `Authorization: Bearer <token>`
* header (HTTP). When `AUTH_TOKEN` is unset every caller is admitted as the
* built-in "default" admin user.
*
* All callers share the same user identity. Use this provider for single-
* user or trusted-network deployments where you just want a simple password.
*/
export class TokenAuthAdapter implements AuthAdapter {
private readonly token: string | undefined;
private readonly adminUsers: ReadonlySet<string>;
constructor() {
this.token = process.env.AUTH_TOKEN;
this.adminUsers = new Set(
(process.env.ADMIN_USERS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
}
authenticate(req: IncomingMessage): AuthUser | null {
if (!this.token) {
return { id: "default", isAdmin: true };
}
if (this.extractToken(req) === this.token) {
return {
id: "default",
isAdmin: this.adminUsers.size === 0 || this.adminUsers.has("default"),
};
}
return null;
}
setupRoutes(_app: Application): void {
// Token auth needs no login/callback/logout routes.
}
requireAuth(req: Request, res: Response, next: NextFunction): void {
const user = this.authenticate(req as unknown as IncomingMessage);
if (!user) {
res.status(401).json({ error: "Unauthorized" });
return;
}
(req as AuthenticatedRequest).user = user;
next();
}
// ── Internals ─────────────────────────────────────────────────────────────
private extractToken(req: IncomingMessage): string | null {
// 1. ?token= query param (used by WebSocket clients and simple links)
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
const qp = url.searchParams.get("token");
if (qp) return qp;
// 2. Authorization: Bearer <token>
const auth = req.headers["authorization"];
if (auth?.startsWith("Bearer ")) return auth.slice(7);
// 3. Cookie cc_token (set by token-auth login page if any)
const cookieHeader = req.headers.cookie ?? "";
for (const part of cookieHeader.split(";")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
if (part.slice(0, eq).trim() === "cc_token") {
return decodeURIComponent(part.slice(eq + 1).trim());
}
}
return null;
}
}

View File

@@ -0,0 +1,326 @@
import express from "express";
import { createServer } from "http";
import { mkdirSync } from "fs";
import path from "path";
import { spawn } from "node-pty";
import { WebSocketServer } from "ws";
import type { IncomingMessage } from "http";
import { ConnectionRateLimiter } from "./auth.js";
import { SessionManager } from "./session-manager.js";
import { UserStore } from "./user-store.js";
import { createAdminRouter } from "./admin.js";
import { SessionStore } from "./auth/adapter.js";
import { TokenAuthAdapter } from "./auth/token-auth.js";
import { OAuthAdapter } from "./auth/oauth-auth.js";
import { ApiKeyAdapter } from "./auth/apikey-auth.js";
import type { AuthAdapter, AuthUser } from "./auth/adapter.js";
// ── Configuration ─────────────────────────────────────────────────────────────
const PORT = parseInt(process.env.PORT ?? "3000", 10);
const HOST = process.env.HOST ?? "0.0.0.0";
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS ?? "10", 10);
const MAX_SESSIONS_PER_USER = parseInt(process.env.MAX_SESSIONS_PER_USER ?? "3", 10);
const MAX_SESSIONS_PER_HOUR = parseInt(process.env.MAX_SESSIONS_PER_HOUR ?? "10", 10);
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS?.split(",") ?? [];
const GRACE_PERIOD_MS = parseInt(process.env.SESSION_GRACE_MS ?? String(5 * 60_000), 10);
const SCROLLBACK_BYTES = parseInt(process.env.SCROLLBACK_BYTES ?? String(100 * 1024), 10);
const CLAUDE_BIN = process.env.CLAUDE_BIN ?? "claude";
const AUTH_PROVIDER = process.env.AUTH_PROVIDER ?? "token";
const SESSION_SECRET = process.env.SESSION_SECRET ?? crypto.randomUUID();
const USER_HOME_BASE = process.env.USER_HOME_BASE ?? "/home/claude/users";
// ── Auth adapter ──────────────────────────────────────────────────────────────
const sessionStore = new SessionStore(SESSION_SECRET);
let authAdapter: AuthAdapter;
switch (AUTH_PROVIDER) {
case "oauth":
authAdapter = new OAuthAdapter(sessionStore);
break;
case "apikey":
authAdapter = new ApiKeyAdapter(sessionStore);
break;
default:
authAdapter = new TokenAuthAdapter();
}
// ── Express app ───────────────────────────────────────────────────────────────
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
const server = createServer(app);
// Register auth routes (login, callback, logout) before static files so they
// take priority over any index.html fallback.
authAdapter.setupRoutes(app);
// ── User store ────────────────────────────────────────────────────────────────
const userStore = new UserStore();
// ── Session Manager ───────────────────────────────────────────────────────────
/** Returns the user-specific home directory, creating it if needed. */
function userHomeDir(userId: string): string {
const dir = path.join(USER_HOME_BASE, userId);
try {
mkdirSync(path.join(dir, ".claude"), { recursive: true });
} catch {
// Already exists or no permission — fail silently; PTY spawn will surface any real issue.
}
return dir;
}
const sessionManager = new SessionManager(
MAX_SESSIONS,
(cols, rows, user?: AuthUser) => {
const userId = user?.id ?? "default";
const home = userHomeDir(userId);
return spawn(CLAUDE_BIN, [], {
name: "xterm-256color",
cols,
rows,
cwd: process.env.WORK_DIR ?? home,
env: {
...process.env,
TERM: "xterm-256color",
COLORTERM: "truecolor",
HOME: home,
// Inject the user's own API key when using apikey auth provider.
...(user?.apiKey ? { ANTHROPIC_API_KEY: user.apiKey } : {}),
},
});
},
GRACE_PERIOD_MS,
SCROLLBACK_BYTES,
MAX_SESSIONS_PER_USER,
MAX_SESSIONS_PER_HOUR,
);
// ── HTTP routes ───────────────────────────────────────────────────────────────
app.get("/health", (_req, res) => {
res.json({
status: "ok",
activeSessions: sessionManager.activeCount,
maxSessions: MAX_SESSIONS,
authProvider: AUTH_PROVIDER,
});
});
/**
* GET /api/sessions — list the current user's sessions.
* Requires authentication. Admins see all sessions.
*/
app.get("/api/sessions", authAdapter.requireAuth.bind(authAdapter), (req, res) => {
const user = (req as express.Request & { user: AuthUser }).user;
const sessions = user.isAdmin
? sessionManager.getAllSessions()
: sessionManager.getUserSessions(user.id);
res.json(sessions);
});
/**
* DELETE /api/sessions/:token — kill a session.
* Users may only kill their own sessions; admins may kill any session.
*/
app.delete(
"/api/sessions/:token",
authAdapter.requireAuth.bind(authAdapter),
(req, res) => {
const { token } = req.params;
const user = (req as express.Request & { user: AuthUser }).user;
const session = sessionManager.getSession(token);
if (!session) {
res.status(404).json({ error: "Session not found" });
return;
}
if (!user.isAdmin && session.userId !== user.id) {
res.status(403).json({ error: "Forbidden" });
return;
}
sessionManager.destroySession(token);
res.status(204).end();
},
);
// Admin routes — protected by admin-role check inside the router.
app.use(
"/admin",
authAdapter.requireAuth.bind(authAdapter),
createAdminRouter(sessionManager, userStore),
);
// Static frontend (served last so auth/admin routes win).
const publicDir = path.join(import.meta.dirname, "public");
app.use(express.static(publicDir));
app.get("/", authAdapter.requireAuth.bind(authAdapter), (_req, res) => {
res.sendFile(path.join(publicDir, "index.html"));
});
// ── WebSocket server ──────────────────────────────────────────────────────────
/**
* Extend IncomingMessage to carry the authenticated user through from
* verifyClient to the connection handler without re-authenticating.
*/
interface AuthedRequest extends IncomingMessage {
_authUser?: AuthUser;
}
const rateLimiter = new ConnectionRateLimiter();
const rateLimiterCleanup = setInterval(() => rateLimiter.cleanup(), 5 * 60_000);
const wss = new WebSocketServer({
server,
path: "/ws",
verifyClient: ({ req, origin }, callback) => {
// Origin check
if (ALLOWED_ORIGINS.length > 0 && !ALLOWED_ORIGINS.includes(origin)) {
console.warn(`Rejected connection from origin: ${origin}`);
callback(false, 403, "Forbidden origin");
return;
}
// Authenticate the user
const user = authAdapter.authenticate(req as IncomingMessage);
if (!user) {
console.warn("Rejected WebSocket connection: unauthenticated");
callback(false, 401, "Unauthorized");
return;
}
// IP-level rate limit (guards against connection floods from a single IP)
const ip =
(req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ??
req.socket.remoteAddress ??
"unknown";
if (!rateLimiter.allow(ip)) {
console.warn(`Rate limited connection from ${ip}`);
callback(false, 429, "Too many connections");
return;
}
// Per-user rate limit (hourly new-session quota)
if (sessionManager.isUserRateLimited(user.id)) {
const retryAfter = sessionManager.retryAfterSeconds(user.id);
console.warn(`Per-user rate limit for ${user.id}`);
callback(false, 429, "Too Many Requests", { "Retry-After": String(retryAfter) });
return;
}
// Per-user concurrent session limit
if (sessionManager.isUserAtConcurrentLimit(user.id)) {
console.warn(`Concurrent session limit reached for ${user.id}`);
callback(false, 429, "Session limit reached");
return;
}
// Attach user to request for the connection handler
(req as AuthedRequest)._authUser = user;
callback(true);
},
});
wss.on("connection", (ws, req) => {
const user = (req as AuthedRequest)._authUser;
if (!user) {
// Should never happen — verifyClient already checked, but be safe.
ws.close(1008, "Unauthenticated");
return;
}
const ip =
(req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ??
req.socket.remoteAddress ??
"unknown";
console.log(`New WebSocket connection from ${ip} (user: ${user.id})`);
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
const cols = parseInt(url.searchParams.get("cols") ?? "80", 10);
const rows = parseInt(url.searchParams.get("rows") ?? "24", 10);
const resumeToken = url.searchParams.get("resume");
// Try to resume an existing session owned by this user
if (resumeToken) {
const stored = sessionManager.getSession(resumeToken);
// Users may only resume their own sessions (admins can resume any)
if (stored && (user.isAdmin || stored.userId === user.id)) {
const resumed = sessionManager.resume(resumeToken, ws, cols, rows);
if (resumed) return;
}
console.log(
`[resume] Session ${resumeToken.slice(0, 8)}… not found or not owned — starting fresh`,
);
}
// Global capacity check
if (sessionManager.isFull) {
ws.send(JSON.stringify({ type: "error", message: "Max sessions reached. Try again later." }));
ws.close(1013, "Max sessions reached");
return;
}
const token = sessionManager.create(ws, cols, rows, user);
if (token) {
// Track the user in the user store
userStore.touch(user.id, { email: user.email, name: user.name });
// Release the user slot when this session ends
const stored = sessionManager.getSession(token);
if (stored) {
stored.pty.onExit(() => userStore.release(user.id));
}
ws.send(JSON.stringify({ type: "session", token }));
}
});
// ── Graceful shutdown ─────────────────────────────────────────────────────────
function shutdown() {
console.log("Shutting down...");
clearInterval(rateLimiterCleanup);
sessionManager.destroyAll();
wss.close(() => {
server.close(() => {
console.log("Server closed.");
process.exit(0);
});
});
setTimeout(() => {
console.error("Forced shutdown after timeout");
process.exit(1);
}, 10_000);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
// ── Start ─────────────────────────────────────────────────────────────────────
server.listen(PORT, HOST, () => {
console.log(`PTY server listening on http://${HOST}:${PORT}`);
console.log(` WebSocket: ws://${HOST}:${PORT}/ws`);
console.log(` Max sessions: ${MAX_SESSIONS} (${MAX_SESSIONS_PER_USER} per user)`);
console.log(` Session grace period: ${GRACE_PERIOD_MS / 1000}s`);
console.log(` Scrollback buffer: ${Math.round(SCROLLBACK_BYTES / 1024)}KB per session`);
console.log(` Auth provider: ${AUTH_PROVIDER}`);
if (AUTH_PROVIDER === "token" && process.env.AUTH_TOKEN) {
console.log(" Auth: token required");
}
if (process.env.ADMIN_USERS) {
console.log(` Admins: ${process.env.ADMIN_USERS}`);
}
});
export { app, server, sessionManager, wss };

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#1a1b26"/>
<path d="M8 22l4-12h1.5L9.5 22H8zm7 0l4-12h1.5L16.5 22H15z" fill="#7aa2f7"/>
<rect x="7" y="23" width="18" height="2" rx="1" fill="#c0caf5"/>
</svg>

After

Width:  |  Height:  |  Size: 281 B

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#1a1b26" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f5" media="(prefers-color-scheme: light)">
<title>Claude Code</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<!-- terminal.css is emitted by esbuild: xterm.css + styles.css bundled together -->
<link rel="stylesheet" href="terminal.css">
</head>
<body>
<!-- Top bar -->
<div id="top-bar">
<div class="left">
<span id="status-dot" class="status-dot connecting"></span>
<span>Claude Code</span>
<span id="latency">--</span>
</div>
<div class="right">
<button id="bar-btn">Reconnect</button>
</div>
</div>
<!-- Re-expand button shown when top bar is collapsed -->
<button id="toggle-bar" title="Show top bar">&#9662;</button>
<!-- Terminal mount point -->
<div id="terminal-container"></div>
<!-- Loading overlay (initial connect) -->
<div id="loading-overlay">
<div class="spinner"></div>
<div class="overlay-msg">Connecting to Claude Code…</div>
</div>
<!-- Reconnect overlay (shown on disconnect) -->
<div id="reconnect-overlay">
<div class="spinner"></div>
<div class="overlay-msg">Connection lost. Reconnecting…</div>
<div class="overlay-sub" id="reconnect-sub">Retrying in 1s…</div>
</div>
<!-- terminal.js is the esbuild bundle of terminal.ts + all xterm addons -->
<script type="module" src="terminal.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,66 @@
/**
* Circular byte buffer for PTY scrollback replay.
* Stores the last `capacity` bytes of PTY output; oldest bytes are silently
* discarded when the buffer is full.
*/
export class ScrollbackBuffer {
private buf: Buffer;
private writePos = 0; // next write position in the ring
private stored = 0; // bytes currently stored (≤ capacity)
readonly capacity: number;
constructor(capacityBytes = 100 * 1024) {
this.capacity = capacityBytes;
this.buf = Buffer.allocUnsafe(capacityBytes);
}
/**
* Append PTY output to the buffer.
* Uses 'binary' (latin1) encoding to preserve raw byte values from node-pty.
*/
write(data: string): void {
const src = Buffer.from(data, "binary");
const len = src.length;
if (len === 0) return;
if (len >= this.capacity) {
// Incoming chunk larger than the whole buffer — keep only the tail
src.copy(this.buf, 0, len - this.capacity);
this.writePos = 0;
this.stored = this.capacity;
return;
}
const end = this.writePos + len;
if (end <= this.capacity) {
src.copy(this.buf, this.writePos);
} else {
// Wrap around the ring boundary
const tailLen = this.capacity - this.writePos;
src.copy(this.buf, this.writePos, 0, tailLen);
src.copy(this.buf, 0, tailLen);
}
this.writePos = end % this.capacity;
this.stored = Math.min(this.stored + len, this.capacity);
}
/**
* Returns all buffered bytes in chronological order (oldest first).
* The returned Buffer is a copy and safe to send over a WebSocket.
*/
read(): Buffer {
if (this.stored === 0) return Buffer.alloc(0);
if (this.stored < this.capacity) {
// Buffer hasn't wrapped yet — contiguous region starting at index 0
return Buffer.from(this.buf.subarray(0, this.stored));
}
// Buffer is full and has wrapped — oldest data starts at writePos
const out = Buffer.allocUnsafe(this.capacity);
const tailLen = this.capacity - this.writePos;
this.buf.copy(out, 0, this.writePos); // tail (oldest)
this.buf.copy(out, tailLen, 0, this.writePos); // head (newest)
return out;
}
}

View File

@@ -0,0 +1,328 @@
import type { IPty } from "node-pty";
import type { WebSocket } from "ws";
import { SessionStore } from "./session-store.js";
import type { AuthUser } from "./auth/adapter.js";
export type { SessionInfo } from "./session-store.js";
// ── Per-user hourly rate limiter ─────────────────────────────────────────────
/**
* Tracks new-session creations per user within a rolling 1-hour window.
*
* `allow(userId)` is a non-destructive peek so callers can check eligibility
* before committing. `record(userId)` commits an attempt (call only on
* successful creation).
*/
export class UserHourlyRateLimiter {
private readonly attempts = new Map<string, number[]>();
private readonly maxPerHour: number;
constructor(maxPerHour: number) {
this.maxPerHour = maxPerHour;
setInterval(() => this.cleanup(), 5 * 60_000).unref();
}
allow(userId: string): boolean {
return this.recent(userId).length < this.maxPerHour;
}
record(userId: string): void {
const r = this.recent(userId);
r.push(Date.now());
this.attempts.set(userId, r);
}
/** Seconds until the oldest attempt in the window falls off (for Retry-After). */
retryAfterSeconds(userId: string): number {
const r = this.recent(userId);
if (r.length === 0) return 0;
return Math.ceil((Math.min(...r) + 3_600_000 - Date.now()) / 1000);
}
private recent(userId: string): number[] {
const cutoff = Date.now() - 3_600_000;
const filtered = (this.attempts.get(userId) ?? []).filter((t) => t > cutoff);
this.attempts.set(userId, filtered);
return filtered;
}
private cleanup(): void {
const cutoff = Date.now() - 3_600_000;
for (const [id, ts] of this.attempts) {
const r = ts.filter((t) => t > cutoff);
if (r.length === 0) this.attempts.delete(id);
else this.attempts.set(id, r);
}
}
}
// ── SessionManager ────────────────────────────────────────────────────────────
export class SessionManager {
private store: SessionStore;
private maxSessions: number;
private maxSessionsPerUser: number;
private spawnPty: (cols: number, rows: number, user?: AuthUser) => IPty;
private rateLimiter: UserHourlyRateLimiter;
// Tracks which sessions have already had their PTY event listeners wired,
// so we don't double-register on reconnect.
private wiredPtys = new Set<string>();
constructor(
maxSessions: number,
spawnPty: (cols: number, rows: number, user?: AuthUser) => IPty,
gracePeriodMs?: number,
scrollbackBytes?: number,
maxSessionsPerUser?: number,
maxSessionsPerHour?: number,
) {
this.maxSessions = maxSessions;
this.maxSessionsPerUser = maxSessionsPerUser ?? maxSessions;
this.spawnPty = spawnPty;
this.store = new SessionStore(gracePeriodMs, scrollbackBytes);
this.rateLimiter = new UserHourlyRateLimiter(maxSessionsPerHour ?? 100);
}
get activeCount(): number {
return this.store.size;
}
get isFull(): boolean {
return this.store.size >= this.maxSessions;
}
getSession(token: string) {
return this.store.get(token);
}
listSessions() {
return this.store.list();
}
/** All sessions in the shape expected by the admin dashboard. */
getAllSessions(): Array<{ id: string; userId: string; createdAt: number }> {
return this.store.getAll().map((s) => ({
id: s.token,
userId: s.userId,
createdAt: s.createdAt.getTime(),
}));
}
/** Sessions owned by a specific user — used by the per-user API. */
getUserSessions(userId: string) {
return this.store.listByUser(userId);
}
isUserAtConcurrentLimit(userId: string): boolean {
return this.store.countByUser(userId) >= this.maxSessionsPerUser;
}
isUserRateLimited(userId: string): boolean {
return !this.rateLimiter.allow(userId);
}
retryAfterSeconds(userId: string): number {
return this.rateLimiter.retryAfterSeconds(userId);
}
/**
* Spawns a new PTY, registers it in the session store, and wires up all
* event plumbing between the PTY and the WebSocket.
*
* Returns the session token, or null if at capacity or PTY spawn fails.
* When `user` is provided the session is associated with that user and
* per-user limits are enforced.
*/
create(ws: WebSocket, cols = 80, rows = 24, user?: AuthUser): string | null {
if (this.isFull) return null;
const userId = user?.id ?? "default";
if (this.isUserAtConcurrentLimit(userId)) {
ws.send(
JSON.stringify({
type: "error",
message: `Session limit reached for your account (max ${this.maxSessionsPerUser}).`,
}),
);
ws.close(1013, "Per-user session limit reached");
return null;
}
if (this.isUserRateLimited(userId)) {
ws.send(
JSON.stringify({
type: "error",
message: "Too many sessions created recently. Please wait before starting a new session.",
}),
);
ws.close(1013, "Rate limited");
return null;
}
let pty: IPty;
try {
pty = this.spawnPty(cols, rows, user);
} catch (err) {
const message =
err instanceof Error ? err.message : "Unknown PTY spawn error";
ws.send(
JSON.stringify({ type: "error", message: `PTY spawn failed: ${message}` }),
);
ws.close(1011, "PTY spawn failure");
return null;
}
// Record the creation only after a successful spawn.
this.rateLimiter.record(userId);
const session = this.store.register(pty, userId);
session.ws = ws;
const { token } = session;
this.wirePtyEvents(token, pty);
this.wireWsEvents(token, ws, pty);
console.log(
`[session ${token.slice(0, 8)}] Created for user ${userId} (active: ${this.store.size}/${this.maxSessions})`,
);
return token;
}
/**
* Attaches a new WebSocket to an existing session identified by `token`.
*
* - Cancels the grace timer
* - Sends `{ type: "resumed", token }` to the client
* - Replays the scrollback buffer so the user sees their conversation
* - Resizes the PTY to the client's current terminal dimensions
*
* Returns true if the session was found, false otherwise.
*/
resume(token: string, ws: WebSocket, cols: number, rows: number): boolean {
const session = this.store.reattach(token, ws);
if (!session) return false;
console.log(
`[session ${token.slice(0, 8)}] Resumed (active: ${this.store.size}/${this.maxSessions})`,
);
// Tell the client it's a resumed session BEFORE sending scrollback bytes.
// The client uses this to clear the terminal first.
ws.send(JSON.stringify({ type: "resumed", token }));
// Replay buffered output
const scrollback = session.scrollback.read();
if (scrollback.length > 0) {
ws.send(scrollback);
}
// Sync PTY dimensions to the reconnected client
try {
session.pty.resize(cols, rows);
} catch {
// PTY may have exited
}
this.wireWsEvents(token, ws, session.pty);
return true;
}
/**
* Wire PTY → scrollback + WebSocket.
* Called once per session lifetime (idempotent via `wiredPtys` guard).
*/
private wirePtyEvents(token: string, pty: IPty): void {
if (this.wiredPtys.has(token)) return;
this.wiredPtys.add(token);
const session = this.store.get(token);
if (!session) return;
pty.onData((data: string) => {
// Always capture to scrollback for future replay
session.scrollback.write(data);
// Forward to the currently attached WebSocket, if any
const ws = session.ws;
if (ws && ws.readyState === 1 /* OPEN */) {
ws.send(data);
}
});
pty.onExit(({ exitCode, signal }) => {
this.wiredPtys.delete(token);
console.log(
`[session ${token.slice(0, 8)}] PTY exited: code=${exitCode}, signal=${signal}`,
);
const ws = session.ws;
if (ws && ws.readyState === 1 /* OPEN */) {
ws.send(JSON.stringify({ type: "exit", exitCode, signal }));
ws.close(1000, "PTY exited");
}
this.store.destroy(token);
});
}
/**
* Wire WebSocket → PTY (input, resize, ping).
* On close/error, start the grace period instead of immediately destroying
* the session — this keeps the PTY alive for reconnection.
* Called once per WebSocket connection (safe to call again on reconnect).
*/
private wireWsEvents(token: string, ws: WebSocket, pty: IPty): void {
ws.on("message", (data: Buffer | string) => {
const str = data.toString();
if (str.startsWith("{")) {
try {
const msg = JSON.parse(str) as Record<string, unknown>;
if (
msg.type === "resize" &&
typeof msg.cols === "number" &&
typeof msg.rows === "number"
) {
pty.resize(msg.cols as number, msg.rows as number);
return;
}
if (msg.type === "ping") {
if (ws.readyState === 1 /* OPEN */) {
ws.send(JSON.stringify({ type: "pong" }));
}
return;
}
} catch {
// Not JSON — treat as terminal input
}
}
pty.write(str);
});
const handleClose = () => {
console.log(`[session ${token.slice(0, 8)}] WebSocket closed`);
const session = this.store.get(token);
// Only start grace if this WS is still the one attached to the session
if (session && session.ws === ws) {
this.store.startGrace(token, () => {
/* logged inside startGrace */
});
}
};
ws.on("close", handleClose);
ws.on("error", (err) => {
console.error(`[session ${token.slice(0, 8)}] WebSocket error:`, err.message);
handleClose();
});
}
/**
* Force-kill a session immediately (used by the REST API).
*/
destroySession(token: string): void {
this.store.destroy(token);
}
destroyAll(): void {
this.store.destroyAll();
}
}

View File

@@ -0,0 +1,197 @@
import type { IPty } from "node-pty";
import type { WebSocket } from "ws";
import { ScrollbackBuffer } from "./scrollback-buffer.js";
const DEFAULT_GRACE_MS = 5 * 60_000; // 5 minutes
const DEFAULT_SCROLLBACK_BYTES = 100 * 1024; // 100 KB
export type StoredSession = {
token: string;
/** ID of the user who owns this session. */
userId: string;
pty: IPty;
scrollback: ScrollbackBuffer;
ws: WebSocket | null;
createdAt: Date;
lastActive: Date;
graceTimer: ReturnType<typeof setTimeout> | null;
};
export type SessionInfo = {
token: string;
/** ID of the user who owns this session. */
userId: string;
created: string;
lastActive: string;
alive: boolean;
};
/**
* In-memory session store with TTL-based cleanup.
*
* Sessions survive WebSocket disconnects for `gracePeriodMs` before being
* permanently destroyed. This lets clients reconnect and resume their PTY
* without losing conversation state.
*/
export class SessionStore {
private sessions = new Map<string, StoredSession>();
private readonly gracePeriodMs: number;
private readonly scrollbackBytes: number;
constructor(
gracePeriodMs = DEFAULT_GRACE_MS,
scrollbackBytes = DEFAULT_SCROLLBACK_BYTES,
) {
this.gracePeriodMs = gracePeriodMs;
this.scrollbackBytes = scrollbackBytes;
}
/**
* Register a newly spawned PTY under a fresh session token.
* @param userId - ID of the owning user (defaults to "default" for single-user deployments).
*/
register(pty: IPty, userId = "default"): StoredSession {
const token = crypto.randomUUID();
const session: StoredSession = {
token,
userId,
pty,
scrollback: new ScrollbackBuffer(this.scrollbackBytes),
ws: null,
createdAt: new Date(),
lastActive: new Date(),
graceTimer: null,
};
this.sessions.set(token, session);
return session;
}
get(token: string): StoredSession | undefined {
return this.sessions.get(token);
}
/**
* Attach a new WebSocket to an existing session.
* Cancels any running grace timer.
* Returns null if the session does not exist.
*/
reattach(token: string, ws: WebSocket): StoredSession | null {
const session = this.sessions.get(token);
if (!session) return null;
if (session.graceTimer) {
clearTimeout(session.graceTimer);
session.graceTimer = null;
}
session.ws = ws;
session.lastActive = new Date();
return session;
}
/**
* Detach the WebSocket from a session and start the grace period timer.
* After `gracePeriodMs` with no reconnect, `onExpire` is called and the
* session is destroyed.
*/
startGrace(token: string, onExpire: () => void): void {
const session = this.sessions.get(token);
if (!session) return;
session.ws = null;
session.lastActive = new Date();
if (session.graceTimer) {
clearTimeout(session.graceTimer);
}
const remainingSec = Math.round(this.gracePeriodMs / 1000);
console.log(
`[session ${token.slice(0, 8)}] Disconnected — grace period: ${remainingSec}s`,
);
session.graceTimer = setTimeout(() => {
session.graceTimer = null;
console.log(
`[session ${token.slice(0, 8)}] Grace period expired — cleaning up`,
);
this.destroy(token);
onExpire();
}, this.gracePeriodMs);
}
/**
* Immediately kill the PTY and remove the session.
*/
destroy(token: string): void {
const session = this.sessions.get(token);
if (!session) return;
this.sessions.delete(token);
if (session.graceTimer) {
clearTimeout(session.graceTimer);
session.graceTimer = null;
}
if (
session.ws &&
session.ws.readyState !== 2 /* CLOSING */ &&
session.ws.readyState !== 3 /* CLOSED */
) {
session.ws.close(1000, "Session destroyed");
}
try {
session.pty.kill("SIGHUP");
} catch {
// PTY may already be dead
}
setTimeout(() => {
try {
session.pty.kill("SIGKILL");
} catch {
// Already dead
}
}, 5000);
}
/** Returns summary info for all sessions (used by the REST API). */
list(): SessionInfo[] {
return [...this.sessions.values()].map((s) => ({
token: s.token,
userId: s.userId,
created: s.createdAt.toISOString(),
lastActive: s.lastActive.toISOString(),
alive: s.ws !== null && s.ws.readyState === 1 /* OPEN */,
}));
}
/** Returns summary info for sessions owned by a specific user. */
listByUser(userId: string): SessionInfo[] {
return this.list().filter((s) => s.userId === userId);
}
/** How many sessions are owned by the given user. */
countByUser(userId: string): number {
let n = 0;
for (const s of this.sessions.values()) {
if (s.userId === userId) n++;
}
return n;
}
/** Returns all raw StoredSession objects (used internally by SessionManager). */
getAll(): StoredSession[] {
return [...this.sessions.values()];
}
get size(): number {
return this.sessions.size;
}
destroyAll(): void {
for (const token of [...this.sessions.keys()]) {
this.destroy(token);
}
}
}

243
src/server/web/styles.css Normal file
View File

@@ -0,0 +1,243 @@
/* ── CSS custom properties (theme tokens) ───────────────────────────────── */
:root {
--term-bg: #1a1b26;
--term-fg: #c0caf5;
--term-cursor: #c0caf5;
--term-selection: rgba(130, 170, 255, 0.3);
--term-black: #15161e;
--term-red: #f7768e;
--term-green: #9ece6a;
--term-yellow: #e0af68;
--term-blue: #7aa2f7;
--term-magenta: #bb9af7;
--term-cyan: #7dcfff;
--term-white: #a9b1d6;
--term-bright-black: #414868;
--term-bright-white: #c0caf5;
--bar-bg: #16161e;
--bar-fg: #565f89;
--bar-accent: #7aa2f7;
--overlay-bg: rgba(26, 27, 38, 0.92);
}
@media (prefers-color-scheme: light) {
:root {
--term-bg: #f5f5f5;
--term-fg: #343b58;
--term-cursor: #343b58;
--term-selection: rgba(52, 59, 88, 0.2);
--term-black: #0f0f14;
--term-red: #8c4351;
--term-green: #485e30;
--term-yellow: #8f5e15;
--term-blue: #34548a;
--term-magenta: #5a4a78;
--term-cyan: #0f4b6e;
--term-white: #343b58;
--term-bright-black: #9699a3;
--term-bright-white: #343b58;
--bar-bg: #e8e8e8;
--bar-fg: #6c7086;
--bar-accent: #34548a;
--overlay-bg: rgba(245, 245, 245, 0.92);
}
}
/* ── Reset ──────────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--term-bg);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
}
/* ── Top bar ────────────────────────────────────────────────────────────── */
#top-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 12px;
background: var(--bar-bg);
color: var(--bar-fg);
font-size: 12px;
user-select: none;
-webkit-user-select: none;
}
#top-bar.collapsed {
display: none;
}
#top-bar .left,
#top-bar .right {
display: flex;
align-items: center;
gap: 10px;
}
/* Re-expand button — only visible when bar is collapsed */
#toggle-bar {
display: none;
position: fixed;
top: 4px;
right: 4px;
z-index: 20;
width: 20px;
height: 20px;
background: var(--bar-bg);
border: 1px solid var(--bar-fg);
border-radius: 3px;
color: var(--bar-fg);
cursor: pointer;
font-size: 10px;
align-items: center;
justify-content: center;
}
#top-bar.collapsed ~ #toggle-bar {
display: flex;
}
/* ── Status dot ─────────────────────────────────────────────────────────── */
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--term-green);
transition: background 0.3s;
}
.status-dot.disconnected {
background: var(--term-red);
}
.status-dot.connecting {
background: var(--term-yellow);
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* ── Bar button ─────────────────────────────────────────────────────────── */
#bar-btn {
background: none;
border: 1px solid var(--bar-fg);
color: var(--bar-fg);
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
transition: color 0.15s, border-color 0.15s;
}
#bar-btn:hover {
color: var(--bar-accent);
border-color: var(--bar-accent);
}
/* ── Terminal container ─────────────────────────────────────────────────── */
#terminal-container {
width: 100%;
height: calc(100vh - 32px);
background: var(--term-bg);
}
#top-bar.collapsed ~ #terminal-container {
height: 100vh;
}
/* Give xterm a little breathing room */
#terminal-container .xterm {
padding: 4px;
}
/* ── Overlays (loading + reconnect) ─────────────────────────────────────── */
#loading-overlay,
#reconnect-overlay {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
background: var(--overlay-bg);
z-index: 100;
}
#loading-overlay {
transition: opacity 0.3s ease;
}
#loading-overlay.hidden {
opacity: 0;
pointer-events: none;
}
#reconnect-overlay {
display: none;
}
#reconnect-overlay.visible {
display: flex;
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--term-bright-black);
border-top-color: var(--bar-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.overlay-msg {
color: var(--term-fg);
font-size: 14px;
opacity: 0.85;
}
.overlay-sub {
margin-top: 6px;
color: var(--bar-fg);
font-size: 12px;
}
/* ── Scrollbar ──────────────────────────────────────────────────────────── */
.xterm-viewport::-webkit-scrollbar { width: 8px; }
.xterm-viewport::-webkit-scrollbar-track { background: transparent; }
.xterm-viewport::-webkit-scrollbar-thumb { background: var(--term-bright-black); border-radius: 4px; }
.xterm-viewport::-webkit-scrollbar-thumb:hover { background: var(--bar-fg); }
/* ── Mobile ─────────────────────────────────────────────────────────────── */
@media (max-width: 600px) {
#top-bar {
height: 28px;
font-size: 11px;
padding: 0 8px;
}
#terminal-container {
height: calc(100vh - 28px);
}
#top-bar.collapsed ~ #terminal-container {
height: 100vh;
}
}

372
src/server/web/terminal.ts Normal file
View File

@@ -0,0 +1,372 @@
/**
* Claude Code — terminal-in-browser
*
* WebSocket protocol (matches src/server/web/session-manager.ts):
*
* Server → client (text, JSON):
* { type: "connected", sessionId: string }
* { type: "pong" }
* { type: "error", message: string }
* { type: "exit", exitCode: number, signal: number | undefined }
*
* Server → client (text, raw):
* PTY output — plain string, written directly to xterm
*
* Client → server (text):
* { type: "resize", cols: number, rows: number }
* { type: "ping" }
* raw terminal input string
*/
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { SearchAddon } from '@xterm/addon-search'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebglAddon } from '@xterm/addon-webgl'
import '@xterm/xterm/css/xterm.css'
import './styles.css'
// ── Types ────────────────────────────────────────────────────────────────────
type ServerMessage =
| { type: 'connected'; sessionId: string }
| { type: 'pong' }
| { type: 'error'; message: string }
| { type: 'exit'; exitCode: number; signal?: number }
// ── Config ───────────────────────────────────────────────────────────────────
const RECONNECT_BASE_MS = 1_000
const RECONNECT_MAX_MS = 30_000
const PING_INTERVAL_MS = 5_000
// ── State ────────────────────────────────────────────────────────────────────
let ws: WebSocket | null = null
let term: Terminal
let fitAddon: FitAddon
let searchAddon: SearchAddon
let reconnectDelay = RECONNECT_BASE_MS
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let pingTimer: ReturnType<typeof setInterval> | null = null
let lastPingSent = 0
let connected = false
// ── DOM refs ─────────────────────────────────────────────────────────────────
const loadingOverlay = document.getElementById('loading-overlay')!
const reconnectOverlay = document.getElementById('reconnect-overlay')!
const reconnectSub = document.getElementById('reconnect-sub')!
const statusDot = document.getElementById('status-dot')!
const latencyEl = document.getElementById('latency')!
const barBtn = document.getElementById('bar-btn') as HTMLButtonElement
const topBar = document.getElementById('top-bar')!
const toggleBarBtn = document.getElementById('toggle-bar') as HTMLButtonElement
const terminalContainer = document.getElementById('terminal-container')!
// ── Theme ────────────────────────────────────────────────────────────────────
function getTheme(): Terminal['options']['theme'] {
const s = getComputedStyle(document.documentElement)
const v = (prop: string) => s.getPropertyValue(prop).trim()
return {
background: v('--term-bg'),
foreground: v('--term-fg'),
cursor: v('--term-cursor'),
selectionBackground: v('--term-selection'),
black: v('--term-black'),
red: v('--term-red'),
green: v('--term-green'),
yellow: v('--term-yellow'),
blue: v('--term-blue'),
magenta: v('--term-magenta'),
cyan: v('--term-cyan'),
white: v('--term-white'),
brightBlack: v('--term-bright-black'),
brightRed: v('--term-red'),
brightGreen: v('--term-green'),
brightYellow: v('--term-yellow'),
brightBlue: v('--term-blue'),
brightMagenta: v('--term-magenta'),
brightCyan: v('--term-cyan'),
brightWhite: v('--term-bright-white'),
}
}
// ── Terminal initialisation ──────────────────────────────────────────────────
function initTerminal(): void {
term = new Terminal({
cursorBlink: true,
cursorStyle: 'block',
fontFamily:
"'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, 'Courier New', monospace",
fontSize: 14,
lineHeight: 1.2,
theme: getTheme(),
allowProposedApi: true,
scrollback: 10_000,
convertEol: true,
})
fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.loadAddon(new WebLinksAddon())
searchAddon = new SearchAddon()
term.loadAddon(searchAddon)
const unicode11 = new Unicode11Addon()
term.loadAddon(unicode11)
term.unicode.activeVersion = '11'
term.open(terminalContainer)
// WebGL renderer with canvas fallback
try {
const webgl = new WebglAddon()
webgl.onContextLoss(() => webgl.dispose())
term.loadAddon(webgl)
} catch {
// Canvas renderer is already active — no action needed
}
fitAddon.fit()
// Keep terminal fitted to container
const resizeObserver = new ResizeObserver(() => fitAddon.fit())
resizeObserver.observe(terminalContainer)
// Propagate resize to server
term.onResize(({ cols, rows }) => sendJSON({ type: 'resize', cols, rows }))
// Forward all terminal input to PTY
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(data)
})
// Keyboard intercepts (return false = swallow; return true = pass through)
term.attachCustomKeyEventHandler((ev) => {
// Ctrl+Shift+F → in-terminal search
if (ev.ctrlKey && ev.shiftKey && ev.key === 'F') {
if (ev.type === 'keydown') {
const query = window.prompt('Search terminal:')
if (query) searchAddon.findNext(query, { caseSensitive: false, regex: false })
}
return false
}
// Ctrl+Shift+C → copy selection (Linux convention)
if (ev.ctrlKey && ev.shiftKey && ev.key === 'C') {
if (ev.type === 'keydown') {
const sel = term.getSelection()
if (sel) navigator.clipboard.writeText(sel)
}
return false
}
// Ctrl+Shift+V → paste (Linux convention)
if (ev.ctrlKey && ev.shiftKey && ev.key === 'V') {
if (ev.type === 'keydown') {
navigator.clipboard.readText().then((text) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(text)
})
}
return false
}
return true
})
// Update theme when OS preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
term.options.theme = getTheme()
})
}
// ── WebSocket ────────────────────────────────────────────────────────────────
function getWsUrl(): string {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = new URL(`${proto}//${location.host}/ws`)
// Auth token: URL param wins, then localStorage
const params = new URLSearchParams(location.search)
const token = params.get('token') ?? localStorage.getItem('claude-terminal-token')
if (token) {
url.searchParams.set('token', token)
localStorage.setItem('claude-terminal-token', token)
}
// Pass current terminal dimensions so the PTY is spawned at the right size
url.searchParams.set('cols', String(term.cols))
url.searchParams.set('rows', String(term.rows))
return url.toString()
}
function sendJSON(msg: Record<string, unknown>): void {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
}
function connect(): void {
setStatus('connecting')
ws = new WebSocket(getWsUrl())
ws.addEventListener('open', () => {
connected = true
reconnectDelay = RECONNECT_BASE_MS
setStatus('connected')
hideOverlay(loadingOverlay)
hideOverlay(reconnectOverlay)
// Re-sync size in case the window changed while connecting
fitAddon.fit()
sendJSON({ type: 'resize', cols: term.cols, rows: term.rows })
startPing()
})
ws.addEventListener('message', ({ data }: MessageEvent<string>) => {
// All messages from the server are strings.
// Try JSON control message first; fall back to raw PTY output.
if (data.startsWith('{')) {
try {
handleControlMessage(JSON.parse(data) as ServerMessage)
return
} catch {
// Not JSON — fall through to write as PTY output
}
}
term.write(data)
})
ws.addEventListener('close', onDisconnect)
ws.addEventListener('error', () => {
// 'error' always fires before 'close'; let onDisconnect handle reconnect
})
}
function handleControlMessage(msg: ServerMessage): void {
switch (msg.type) {
case 'connected':
// Session established — nothing extra needed
break
case 'pong':
latencyEl.textContent = `${Date.now() - lastPingSent}ms`
break
case 'error':
term.writeln(`\r\n\x1b[31m[error] ${msg.message}\x1b[0m`)
break
case 'exit':
term.writeln(
`\r\n\x1b[33m[session ended — exit code ${msg.exitCode ?? 0}]\x1b[0m`,
)
break
}
}
function onDisconnect(): void {
connected = false
ws = null
setStatus('disconnected')
stopPing()
showOverlay(reconnectOverlay)
scheduleReconnect()
}
function scheduleReconnect(): void {
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectSub.textContent = `Retrying in ${Math.round(reconnectDelay / 1_000)}s…`
reconnectTimer = setTimeout(() => connect(), reconnectDelay)
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS)
}
function manualReconnect(): void {
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectDelay = RECONNECT_BASE_MS
ws?.close()
ws = null
term.clear()
connect()
}
// ── Ping / latency ───────────────────────────────────────────────────────────
function startPing(): void {
stopPing()
pingTimer = setInterval(() => {
if (ws?.readyState === WebSocket.OPEN) {
lastPingSent = Date.now()
sendJSON({ type: 'ping' })
}
}, PING_INTERVAL_MS)
}
function stopPing(): void {
if (pingTimer) clearInterval(pingTimer)
pingTimer = null
latencyEl.textContent = '--'
}
// ── UI helpers ────────────────────────────────────────────────────────────────
function setStatus(state: 'connected' | 'connecting' | 'disconnected'): void {
statusDot.className = 'status-dot'
if (state !== 'connected') statusDot.classList.add(state)
barBtn.textContent = connected ? 'Disconnect' : 'Reconnect'
}
function showOverlay(el: HTMLElement): void {
el.classList.remove('hidden')
el.classList.add('visible')
}
function hideOverlay(el: HTMLElement): void {
el.classList.remove('visible')
el.classList.add('hidden')
}
// ── Top bar collapse ──────────────────────────────────────────────────────────
function setupBarToggle(): void {
const STORAGE_KEY = 'claude-bar-collapsed'
if (localStorage.getItem(STORAGE_KEY) === 'true') {
topBar.classList.add('collapsed')
}
// Show bar button re-expands it
toggleBarBtn.addEventListener('click', () => {
topBar.classList.remove('collapsed')
localStorage.setItem(STORAGE_KEY, 'false')
setTimeout(() => fitAddon.fit(), 200)
})
// Double-click bar to collapse
topBar.addEventListener('dblclick', () => {
topBar.classList.add('collapsed')
localStorage.setItem(STORAGE_KEY, 'true')
setTimeout(() => fitAddon.fit(), 200)
})
barBtn.addEventListener('click', () => {
if (connected) {
ws?.close()
} else {
manualReconnect()
}
})
}
// ── Boot ─────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initTerminal()
setupBarToggle()
connect()
// Keep terminal focused
document.addEventListener('click', () => term.focus())
term.focus()
})

View File

@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": []
},
"include": ["terminal.ts", "styles.css"]
}

View File

@@ -0,0 +1,64 @@
/**
* UserStore — tracks which users are connected and how many sessions each has.
*
* This is a lightweight in-memory view derived from the SessionManager; it
* does not persist across restarts. The admin dashboard and admin API read
* from this store to enumerate users and their activity.
*/
export interface UserRecord {
id: string;
email?: string;
name?: string;
firstSeenAt: number;
lastSeenAt: number;
sessionCount: number;
}
export class UserStore {
private readonly users = new Map<string, UserRecord>();
/**
* Called when a session is created for a user.
* Creates the user record if it doesn't exist yet; increments sessionCount.
*/
touch(userId: string, meta?: { email?: string; name?: string }): void {
const existing = this.users.get(userId);
if (existing) {
existing.lastSeenAt = Date.now();
existing.sessionCount += 1;
if (meta?.email && !existing.email) existing.email = meta.email;
if (meta?.name && !existing.name) existing.name = meta.name;
} else {
this.users.set(userId, {
id: userId,
email: meta?.email,
name: meta?.name,
firstSeenAt: Date.now(),
lastSeenAt: Date.now(),
sessionCount: 1,
});
}
}
/**
* Called when a session is destroyed for a user.
* Decrements sessionCount; removes the record when it reaches zero.
*/
release(userId: string): void {
const record = this.users.get(userId);
if (!record) return;
record.sessionCount = Math.max(0, record.sessionCount - 1);
if (record.sessionCount === 0) {
this.users.delete(userId);
}
}
/** Returns all currently connected users (sessionCount > 0). */
list(): UserRecord[] {
return [...this.users.values()];
}
get(userId: string): UserRecord | undefined {
return this.users.get(userId);
}
}

View File

@@ -6,6 +6,8 @@
const FEATURE_FLAGS: Record<string, boolean> = {
PROACTIVE: envBool('CLAUDE_CODE_PROACTIVE', false),
KAIROS: envBool('CLAUDE_CODE_KAIROS', false),
KAIROS_BRIEF: envBool('CLAUDE_CODE_KAIROS_BRIEF', false),
KAIROS_GITHUB_WEBHOOKS: envBool('CLAUDE_CODE_KAIROS_GITHUB_WEBHOOKS', false),
BRIDGE_MODE: envBool('CLAUDE_CODE_BRIDGE_MODE', false),
DAEMON: envBool('CLAUDE_CODE_DAEMON', false),
VOICE_MODE: envBool('CLAUDE_CODE_VOICE_MODE', false),
@@ -15,6 +17,17 @@ const FEATURE_FLAGS: Record<string, boolean> = {
ABLATION_BASELINE: false, // always off for external builds
DUMP_SYSTEM_PROMPT: envBool('CLAUDE_CODE_DUMP_SYSTEM_PROMPT', false),
BG_SESSIONS: envBool('CLAUDE_CODE_BG_SESSIONS', false),
HISTORY_SNIP: envBool('CLAUDE_CODE_HISTORY_SNIP', false),
WORKFLOW_SCRIPTS: envBool('CLAUDE_CODE_WORKFLOW_SCRIPTS', false),
CCR_REMOTE_SETUP: envBool('CLAUDE_CODE_CCR_REMOTE_SETUP', false),
EXPERIMENTAL_SKILL_SEARCH: envBool('CLAUDE_CODE_EXPERIMENTAL_SKILL_SEARCH', false),
ULTRAPLAN: envBool('CLAUDE_CODE_ULTRAPLAN', false),
TORCH: envBool('CLAUDE_CODE_TORCH', false),
UDS_INBOX: envBool('CLAUDE_CODE_UDS_INBOX', false),
FORK_SUBAGENT: envBool('CLAUDE_CODE_FORK_SUBAGENT', false),
BUDDY: envBool('CLAUDE_CODE_BUDDY', false),
MCP_SKILLS: envBool('CLAUDE_CODE_MCP_SKILLS', false),
REACTIVE_COMPACT: envBool('CLAUDE_CODE_REACTIVE_COMPACT', false),
}
function envBool(key: string, fallback: boolean): boolean {

10
src/types/bun-globals.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
// Bun runtime global type augmentations.
// The bun-types package provides these when installed; this file acts as a
// lightweight fallback so the project type-checks without it.
interface ImportMeta {
/** Bun: absolute path of the directory containing the current file */
dir: string
/** Node 21+ / Bun: same as dir */
dirname: string
}

415
src/types/message.ts Normal file
View File

@@ -0,0 +1,415 @@
/**
* Pure message type definitions extracted to break import cycles.
*
* This file contains only type definitions with no runtime dependencies.
* Message types are discriminated unions based on `.type` field.
* System messages are further discriminated by `.subtype`.
*/
import type { UUID } from 'crypto'
import type {
BetaContentBlock,
BetaMessage,
BetaRawMessageStreamEvent,
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import type {
ContentBlockParam,
} from '@anthropic-ai/sdk/resources/messages.mjs'
import type { APIError } from '@anthropic-ai/sdk'
import type { PermissionMode } from './permissions.js'
// ============================================================================
// Scalar / Enum Types
// ============================================================================
/** Where a message originated. undefined = human (typed at keyboard). */
export type MessageOrigin =
| 'agent'
| 'teammate'
| 'command'
| 'system'
| 'hook'
| undefined
/** Direction for partial compact summarization. */
export type PartialCompactDirection = 'earlier' | 'later'
/** System message severity levels. */
export type SystemMessageLevel = 'info' | 'warning' | 'error'
/** Hook execution info for stop hooks. */
export interface StopHookInfo {
hookName: string
executionTime?: number
success: boolean
error?: string
}
// ============================================================================
// Progress Types
// ============================================================================
/** Generic progress data for ongoing tool operations. */
export interface Progress {
type: string
[key: string]: unknown
}
/** Progress message for streaming tool execution updates. */
export interface ProgressMessage<P extends Progress = Progress> {
type: 'progress'
data: P
toolUseID: string
parentToolUseID: string
uuid: UUID
timestamp: string
}
// ============================================================================
// AssistantMessage
// ============================================================================
export interface AssistantMessage {
type: 'assistant'
uuid: UUID
timestamp: string
message: BetaMessage
requestId?: string
isMeta?: true
isVirtual?: true
isApiErrorMessage?: boolean
apiError?: string
error?: unknown
errorDetails?: string
advisorModel?: string
/** AgentId of the agent that produced this message. */
agentId?: string
/** Caller info for debugging/display. */
caller?: string
}
// ============================================================================
// UserMessage
// ============================================================================
export interface UserMessage {
type: 'user'
message: {
role: 'user'
content: string | ContentBlockParam[]
}
uuid: UUID
timestamp: string
isMeta?: true
isVisibleInTranscriptOnly?: true
isVirtual?: true
isCompactSummary?: true
toolUseResult?: unknown
mcpMeta?: {
_meta?: Record<string, unknown>
structuredContent?: Record<string, unknown>
}
imagePasteIds?: number[]
sourceToolAssistantUUID?: UUID
permissionMode?: PermissionMode
summarizeMetadata?: {
messagesSummarized: number
userContext?: string
direction?: PartialCompactDirection
}
origin?: MessageOrigin
}
// ============================================================================
// SystemMessage (base) + all subtypes
// ============================================================================
/** Base fields shared by all system messages. */
interface SystemMessageBase {
type: 'system'
uuid: UUID
timestamp: string
isMeta?: boolean
content?: string
level?: SystemMessageLevel
toolUseID?: string
}
export interface SystemInformationalMessage extends SystemMessageBase {
subtype: 'informational'
content: string
level: SystemMessageLevel
preventContinuation?: boolean
}
export interface SystemAPIErrorMessage extends SystemMessageBase {
subtype: 'api_error'
level: 'error'
error: APIError
cause?: Error
retryInMs: number
retryAttempt: number
maxRetries: number
}
export interface SystemLocalCommandMessage extends SystemMessageBase {
subtype: 'local_command'
content: string
level?: SystemMessageLevel
}
export interface SystemStopHookSummaryMessage extends SystemMessageBase {
subtype: 'stop_hook_summary'
hookCount: number
hookInfos: StopHookInfo[]
hookErrors: string[]
preventedContinuation: boolean
stopReason: string | undefined
hasOutput: boolean
level: SystemMessageLevel
hookLabel?: string
totalDurationMs?: number
}
export interface SystemBridgeStatusMessage extends SystemMessageBase {
subtype: 'bridge_status'
content: string
url: string
upgradeNudge?: string
}
export interface SystemTurnDurationMessage extends SystemMessageBase {
subtype: 'turn_duration'
durationMs: number
budgetTokens?: number
budgetLimit?: number
budgetNudges?: number
messageCount?: number
}
export interface SystemThinkingMessage extends SystemMessageBase {
subtype: 'thinking'
content: string
}
export interface SystemMemorySavedMessage extends SystemMessageBase {
subtype: 'memory_saved'
writtenPaths: string[]
}
export interface SystemAwaySummaryMessage extends SystemMessageBase {
subtype: 'away_summary'
content: string
}
export interface SystemAgentsKilledMessage extends SystemMessageBase {
subtype: 'agents_killed'
}
export interface SystemCompactBoundaryMessage extends SystemMessageBase {
subtype: 'compact_boundary'
content: string
compactMetadata?: {
trigger: 'manual' | 'auto'
preTokens: number
userContext?: string
messagesSummarized?: number
preservedSegment?: {
tailUuid?: string
headUuid?: string
}
}
logicalParentUuid?: UUID
}
export interface SystemMicrocompactBoundaryMessage extends SystemMessageBase {
subtype: 'microcompact_boundary'
content: string
microcompactMetadata?: {
trigger: 'auto'
preTokens: number
tokensSaved: number
compactedToolIds: string[]
clearedAttachmentUUIDs: string[]
}
}
export interface SystemPermissionRetryMessage extends SystemMessageBase {
subtype: 'permission_retry'
content: string
commands: string[]
}
export interface SystemScheduledTaskFireMessage extends SystemMessageBase {
subtype: 'scheduled_task_fire'
content: string
}
export interface SystemApiMetricsMessage extends SystemMessageBase {
subtype: 'api_metrics'
ttftMs: number
otps: number
isP50?: boolean
hookDurationMs?: number
turnDurationMs?: number
toolDurationMs?: number
classifierDurationMs?: number
toolCount?: number
hookCount?: number
classifierCount?: number
configWriteCount?: number
}
/** Discriminated union of all system message subtypes. */
export type SystemMessage =
| SystemInformationalMessage
| SystemAPIErrorMessage
| SystemLocalCommandMessage
| SystemStopHookSummaryMessage
| SystemBridgeStatusMessage
| SystemTurnDurationMessage
| SystemThinkingMessage
| SystemMemorySavedMessage
| SystemAwaySummaryMessage
| SystemAgentsKilledMessage
| SystemCompactBoundaryMessage
| SystemMicrocompactBoundaryMessage
| SystemPermissionRetryMessage
| SystemScheduledTaskFireMessage
| SystemApiMetricsMessage
// ============================================================================
// AttachmentMessage
// ============================================================================
export interface AttachmentMessage<A extends Record<string, unknown> = Record<string, unknown>> {
type: 'attachment'
attachment: A & { type: string }
uuid: UUID
timestamp: string
isMeta?: true
}
// ============================================================================
// TombstoneMessage
// ============================================================================
export interface TombstoneMessage {
type: 'tombstone'
originalType: 'assistant' | 'user' | 'system'
uuid: UUID
timestamp: string
}
// ============================================================================
// ToolUseSummaryMessage
// ============================================================================
export interface ToolUseSummaryMessage {
type: 'tool_use_summary'
summary: string
precedingToolUseIds: string[]
uuid: UUID
timestamp: string
}
// ============================================================================
// HookResultMessage
// ============================================================================
export interface HookResultMessage {
type: 'user'
message: {
role: 'user'
content: ContentBlockParam[]
}
uuid: UUID
timestamp: string
isMeta?: true
}
// ============================================================================
// Discriminated Message Union
// ============================================================================
/** Union of all message types used in the conversation history. */
export type Message =
| AssistantMessage
| UserMessage
| SystemMessage
| AttachmentMessage
| ProgressMessage
| TombstoneMessage
// ============================================================================
// Grouped / Collapsed display types
// ============================================================================
export interface GroupedToolUseMessage {
type: 'assistant'
uuid: UUID
timestamp: string
message: BetaMessage
toolUseCount: number
}
export interface CollapsedReadSearchGroup {
type: 'assistant'
uuid: UUID
timestamp: string
message: BetaMessage
collapsedCount: number
}
/** Messages that can be rendered in UI. */
export type RenderableMessage =
| AssistantMessage
| UserMessage
| SystemMessage
| AttachmentMessage
| GroupedToolUseMessage
| CollapsedReadSearchGroup
// ============================================================================
// Normalized Message Types
// ============================================================================
/** Normalized assistant message with single content block. */
export interface NormalizedAssistantMessage extends AssistantMessage {
message: BetaMessage & {
content: [BetaContentBlock]
}
}
/** Normalized user message with content always as array. */
export interface NormalizedUserMessage extends UserMessage {
message: {
role: 'user'
content: ContentBlockParam[]
}
}
/** Union of all normalized message types for API processing. */
export type NormalizedMessage =
| NormalizedAssistantMessage
| NormalizedUserMessage
| SystemMessage
| AttachmentMessage
| ProgressMessage
| TombstoneMessage
// ============================================================================
// Stream / Event Types
// ============================================================================
/** Event fired at the start of a stream request. */
export interface RequestStartEvent {
type: 'stream_request_start'
}
/** Wrapper for streaming events from the Anthropic API. */
export interface StreamEvent {
type: 'stream_event'
event: BetaRawMessageStreamEvent
ttftMs?: number
}

View File

@@ -15,7 +15,7 @@
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"lib": ["ESNext"],
"types": ["bun-types", "node"],
"types": ["node"],
"baseUrl": ".",
"paths": {
"bun:bundle": ["./src/types/bun-bundle.d.ts"]

3
web/.env.example Normal file
View File

@@ -0,0 +1,3 @@
NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_WS_URL=ws://localhost:3001
ANTHROPIC_API_KEY=

3
web/.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}

36
web/app/api/chat/route.ts Normal file
View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
const response = await fetch(`${apiUrl}/api/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(process.env.ANTHROPIC_API_KEY
? { Authorization: `Bearer ${process.env.ANTHROPIC_API_KEY}` }
: {}),
},
body: JSON.stringify(body),
});
if (!response.ok) {
return NextResponse.json(
{ error: "Backend request failed" },
{ status: response.status }
);
}
// Stream the response through
return new NextResponse(response.body, {
headers: {
"Content-Type": response.headers.get("Content-Type") ?? "application/json",
},
});
} catch (error) {
console.error("Chat API error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import type { Conversation, ExportOptions } from "@/lib/types";
import { toMarkdown } from "@/lib/export/markdown";
import { toJSON } from "@/lib/export/json";
import { toHTML } from "@/lib/export/html";
import { toPlainText } from "@/lib/export/plaintext";
interface ExportRequest {
conversation: Conversation;
options: ExportOptions;
}
const MIME: Record<string, string> = {
markdown: "text/markdown; charset=utf-8",
json: "application/json",
html: "text/html; charset=utf-8",
plaintext: "text/plain; charset=utf-8",
};
const EXT: Record<string, string> = {
markdown: "md",
json: "json",
html: "html",
plaintext: "txt",
};
export async function POST(req: NextRequest) {
let body: ExportRequest;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { conversation, options } = body;
if (!conversation || !options) {
return NextResponse.json(
{ error: "Missing conversation or options" },
{ status: 400 }
);
}
const { format } = options;
if (format === "pdf") {
// PDF is handled client-side via window.print()
return NextResponse.json(
{ error: "PDF export is handled client-side" },
{ status: 400 }
);
}
let content: string;
switch (format) {
case "markdown":
content = toMarkdown(conversation, options);
break;
case "json":
content = toJSON(conversation, options);
break;
case "html":
content = toHTML(conversation, options);
break;
case "plaintext":
content = toPlainText(conversation, options);
break;
default:
return NextResponse.json({ error: `Unknown format: ${format}` }, { status: 400 });
}
const slug = conversation.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 50);
const filename = `${slug || "conversation"}.${EXT[format]}`;
return new NextResponse(content, {
status: 200,
headers: {
"Content-Type": MIME[format],
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
const IMAGE_MIME: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
ico: "image/x-icon",
};
export async function GET(request: NextRequest) {
const filePath = request.nextUrl.searchParams.get("path");
if (!filePath) {
return NextResponse.json({ error: "path parameter required" }, { status: 400 });
}
const resolvedPath = path.resolve(filePath);
try {
const stats = await fs.stat(resolvedPath);
if (stats.isDirectory()) {
return NextResponse.json({ error: "path is a directory" }, { status: 400 });
}
const ext = resolvedPath.split(".").pop()?.toLowerCase() ?? "";
// Binary images: return base64 data URL
if (ext in IMAGE_MIME) {
const buffer = await fs.readFile(resolvedPath);
const base64 = buffer.toString("base64");
return NextResponse.json({
content: `data:${IMAGE_MIME[ext]};base64,${base64}`,
isImage: true,
size: stats.size,
modified: stats.mtime.toISOString(),
});
}
// Text (including SVG)
const content = await fs.readFile(resolvedPath, "utf-8");
return NextResponse.json({
content,
isImage: ext === "svg",
size: stats.size,
modified: stats.mtime.toISOString(),
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 404 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
export async function POST(request: NextRequest) {
let body: { path?: string; content?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
}
const { path: filePath, content } = body;
if (!filePath || content === undefined) {
return NextResponse.json(
{ error: "path and content are required" },
{ status: 400 }
);
}
const resolvedPath = path.resolve(filePath);
try {
await fs.writeFile(resolvedPath, content, "utf-8");
const stats = await fs.stat(resolvedPath);
return NextResponse.json({ success: true, size: stats.size });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { getShare, revokeShare, verifySharePassword } from "@/lib/share-store";
interface RouteContext {
params: { shareId: string };
}
export async function GET(req: NextRequest, { params }: RouteContext) {
const { shareId } = params;
const share = getShare(shareId);
if (!share) {
return NextResponse.json({ error: "Share not found or expired" }, { status: 404 });
}
if (share.visibility === "password") {
const pw = req.headers.get("x-share-password") ?? req.nextUrl.searchParams.get("password");
if (!pw || !verifySharePassword(shareId, pw)) {
return NextResponse.json({ error: "Password required", requiresPassword: true }, { status: 401 });
}
}
return NextResponse.json({
id: share.id,
title: share.conversation.title,
messages: share.conversation.messages,
model: share.conversation.model,
createdAt: share.conversation.createdAt,
shareCreatedAt: share.createdAt,
});
}
export async function DELETE(_req: NextRequest, { params }: RouteContext) {
const { shareId } = params;
const deleted = revokeShare(shareId);
if (!deleted) {
return NextResponse.json({ error: "Share not found" }, { status: 404 });
}
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { nanoid } from "nanoid";
import type { Conversation } from "@/lib/types";
import type { ShareVisibility, ShareExpiry } from "@/lib/share-store";
import { createShare } from "@/lib/share-store";
interface CreateShareRequest {
conversation: Conversation;
visibility: ShareVisibility;
password?: string;
expiry: ShareExpiry;
}
export async function POST(req: NextRequest) {
let body: CreateShareRequest;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { conversation, visibility, password, expiry } = body;
if (!conversation || !visibility || !expiry) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
if (visibility === "password" && !password) {
return NextResponse.json(
{ error: "Password required for password-protected shares" },
{ status: 400 }
);
}
const shareId = nanoid(12);
const share = createShare(shareId, { conversation, visibility, password, expiry });
const origin = req.headers.get("origin") ?? "";
const url = `${origin}/share/${shareId}`;
return NextResponse.json({
id: share.id,
conversationId: share.conversationId,
visibility: share.visibility,
hasPassword: !!share.passwordHash,
expiry: share.expiry,
expiresAt: share.expiresAt,
createdAt: share.createdAt,
url,
});
}

227
web/app/globals.css Normal file
View File

@@ -0,0 +1,227 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* =====================================================
DESIGN TOKENS — CSS Custom Properties
Dark theme is default; add .light to <html> for light
===================================================== */
@layer base {
:root {
/* Backgrounds */
--color-bg-primary: #09090b;
--color-bg-secondary: #18181b;
--color-bg-elevated: #27272a;
/* Text */
--color-text-primary: #fafafa;
--color-text-secondary: #a1a1aa;
--color-text-muted: #52525b;
/* Accent (brand purple) */
--color-accent: #8b5cf6;
--color-accent-hover: #7c3aed;
--color-accent-active: #6d28d9;
--color-accent-foreground: #ffffff;
/* Borders */
--color-border: #27272a;
--color-border-hover: #3f3f46;
/* Status */
--color-success: #22c55e;
--color-success-bg: rgba(34, 197, 94, 0.12);
--color-warning: #f59e0b;
--color-warning-bg: rgba(245, 158, 11, 0.12);
--color-error: #ef4444;
--color-error-bg: rgba(239, 68, 68, 0.12);
--color-info: #3b82f6;
--color-info-bg: rgba(59, 130, 246, 0.12);
/* Code */
--color-code-bg: #1f1f23;
--color-code-text: #a78bfa;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
/* Border radius */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius: 0.5rem;
/* Animation tokens */
--transition-fast: 100ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
/* Tailwind / shadcn compat (HSL channel values) */
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 3.7% 10%;
--card-foreground: 0 0% 98%;
--popover: 240 3.7% 15.9%;
--popover-foreground: 0 0% 98%;
--primary: 263.4 70% 50.4%;
--primary-foreground: 0 0% 100%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 263.4 70% 50.4%;
}
/* Light theme override */
.light {
--color-bg-primary: #fafafa;
--color-bg-secondary: #f4f4f5;
--color-bg-elevated: #ffffff;
--color-text-primary: #09090b;
--color-text-secondary: #52525b;
--color-text-muted: #a1a1aa;
--color-accent: #7c3aed;
--color-accent-hover: #6d28d9;
--color-accent-active: #5b21b6;
--color-accent-foreground: #ffffff;
--color-border: #e4e4e7;
--color-border-hover: #d4d4d8;
--color-success: #16a34a;
--color-success-bg: rgba(22, 163, 74, 0.1);
--color-warning: #d97706;
--color-warning-bg: rgba(217, 119, 6, 0.1);
--color-error: #dc2626;
--color-error-bg: rgba(220, 38, 38, 0.1);
--color-info: #2563eb;
--color-info-bg: rgba(37, 99, 235, 0.1);
--color-code-bg: #f4f4f5;
--color-code-text: #7c3aed;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.05);
--background: 0 0% 98%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 0 0% 100%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 262.1 83.3% 57.8%;
}
*, *::before, *::after {
box-sizing: border-box;
border-color: var(--color-border);
}
html {
color-scheme: dark;
}
html.light {
color-scheme: light;
}
body {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-feature-settings: "rlig" 1, "calt" 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.5;
}
/* Focus visible ring */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
/* =====================================================
ANIMATIONS
===================================================== */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes slideUp {
from { transform: translateY(8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideDown {
from { transform: translateY(-8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideDownOut {
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(8px); opacity: 0; }
}
@keyframes scaleIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes scaleOut {
from { transform: scale(1); opacity: 1; }
to { transform: scale(0.95); opacity: 0; }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes progress {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
/* =====================================================
SCROLLBAR
===================================================== */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border-hover);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}

56
web/app/layout.tsx Normal file
View File

@@ -0,0 +1,56 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
import { ToastProvider } from "@/components/notifications/ToastProvider";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
const jetbrainsMono = localFont({
src: [
{
path: "../public/fonts/JetBrainsMono-Regular.woff2",
weight: "400",
style: "normal",
},
{
path: "../public/fonts/JetBrainsMono-Medium.woff2",
weight: "500",
style: "normal",
},
],
variable: "--font-jetbrains-mono",
display: "swap",
fallback: ["ui-monospace", "SFMono-Regular", "Menlo", "Monaco", "monospace"],
});
export const metadata: Metadata = {
title: "Claude Code",
description: "Claude Code — AI-powered development assistant",
icons: {
icon: "/favicon.ico",
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="dark" suppressHydrationWarning>
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}>
<ThemeProvider>
<ToastProvider>
{children}
</ToastProvider>
</ThemeProvider>
</body>
</html>
);
}

5
web/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { ChatLayout } from "@/components/chat/ChatLayout";
export default function Home() {
return <ChatLayout />;
}

View File

@@ -0,0 +1,68 @@
"use client";
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react";
interface AnnouncerContextValue {
announce: (message: string, politeness?: "polite" | "assertive") => void;
}
const AnnouncerContext = createContext<AnnouncerContextValue | null>(null);
/**
* Provides a programmatic screen-reader announcement API via context.
* Place <AnnouncerProvider> near the root of the app, then call `useAnnouncer()`
* anywhere to imperatively announce status changes.
*
* @example
* const { announce } = useAnnouncer();
* announce("File uploaded successfully");
* announce("Error: request failed", "assertive");
*/
export function AnnouncerProvider({ children }: { children: ReactNode }) {
const [politeMsg, setPoliteMsg] = useState("");
const [assertiveMsg, setAssertiveMsg] = useState("");
const politeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const assertiveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const announce = useCallback((message: string, politeness: "polite" | "assertive" = "polite") => {
if (politeness === "assertive") {
setAssertiveMsg("");
if (assertiveTimer.current) clearTimeout(assertiveTimer.current);
assertiveTimer.current = setTimeout(() => setAssertiveMsg(message), 50);
} else {
setPoliteMsg("");
if (politeTimer.current) clearTimeout(politeTimer.current);
politeTimer.current = setTimeout(() => setPoliteMsg(message), 50);
}
}, []);
const srStyle: React.CSSProperties = {
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0,0,0,0)",
whiteSpace: "nowrap",
borderWidth: 0,
};
return (
<AnnouncerContext.Provider value={{ announce }}>
{children}
<div aria-live="polite" aria-atomic="true" style={srStyle}>
{politeMsg}
</div>
<div aria-live="assertive" aria-atomic="true" style={srStyle}>
{assertiveMsg}
</div>
</AnnouncerContext.Provider>
);
}
export function useAnnouncer() {
const ctx = useContext(AnnouncerContext);
if (!ctx) throw new Error("useAnnouncer must be used within <AnnouncerProvider>");
return ctx;
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
interface FocusTrapProps {
children: ReactNode;
/** When false, the trap is inactive (e.g. when the panel is hidden) */
active?: boolean;
}
const FOCUSABLE_SELECTORS = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(", ");
/**
* Traps keyboard focus within its children when `active` is true.
* Use for modals, drawers, and other overlay patterns.
* Note: Radix Dialog handles focus trapping natively — use this only for
* custom overlay components that don't use Radix primitives.
*/
export function FocusTrap({ children, active = true }: FocusTrapProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
const focusable = () =>
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)).filter(
(el) => !el.closest("[aria-hidden='true']")
);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const els = focusable();
if (els.length === 0) return;
const first = els[0];
const last = els[els.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
// Move focus into the trap on mount
const els = focusable();
if (els.length > 0 && !container.contains(document.activeElement)) {
els[0].focus();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [active]);
return <div ref={containerRef}>{children}</div>;
}

View File

@@ -0,0 +1,55 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface LiveRegionProps {
/** The message to announce. Changing this value triggers an announcement. */
message: string;
/**
* "polite" — waits for user to be idle (new chat messages, status updates)
* "assertive" — interrupts immediately (errors, critical alerts)
*/
politeness?: "polite" | "assertive";
}
/**
* Managed aria-live region that announces dynamic content to screen readers.
* Clears after 500 ms to ensure repeated identical messages are re-announced.
*/
export function LiveRegion({ message, politeness = "polite" }: LiveRegionProps) {
const [announced, setAnnounced] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!message) return;
// Clear first to force re-announcement of identical messages
setAnnounced("");
timerRef.current = setTimeout(() => setAnnounced(message), 50);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [message]);
return (
<div
role="status"
aria-live={politeness}
aria-atomic="true"
style={{
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
borderWidth: 0,
}}
>
{announced}
</div>
);
}

View File

@@ -0,0 +1,18 @@
"use client";
export function SkipToContent() {
return (
<a
href="#main-content"
className={[
"sr-only focus:not-sr-only",
"focus:fixed focus:top-4 focus:left-4 focus:z-50",
"focus:px-4 focus:py-2 focus:rounded-md",
"focus:bg-brand-600 focus:text-white focus:font-medium focus:text-sm",
"focus:outline-none focus:ring-2 focus:ring-brand-300 focus:ring-offset-2",
].join(" ")}
>
Skip to main content
</a>
);
}

View File

@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
interface VisuallyHiddenProps {
children: ReactNode;
/** When true, renders as a span inline; defaults to span */
as?: "span" | "div" | "p";
}
/**
* Visually hides content while keeping it accessible to screen readers.
* Use for icon-only buttons, supplemental context, etc.
*/
export function VisuallyHidden({ children, as: Tag = "span" }: VisuallyHiddenProps) {
return (
<Tag
style={{
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
borderWidth: 0,
}}
>
{children}
</Tag>
);
}

View File

@@ -0,0 +1,170 @@
"use client";
/**
* Web-adapted Markdown renderer.
*
* The terminal Markdown (src/components/Markdown.tsx) uses marked + Ink's
* <Ansi> / <Box> to render tokenised markdown as coloured ANSI output.
* This web version uses react-markdown + remark-gfm + rehype-highlight, which
* are already present in the web package, to render proper HTML with Tailwind
* prose styles.
*
* Props are intentionally compatible with the terminal version so callers can
* swap between them via the platform conditional.
*/
import * as React from "react";
import { useMemo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface MarkdownProps {
/** Markdown source string — matches the terminal component's children prop. */
children: string;
/** When true, render all text as visually dimmed (muted colour). */
dimColor?: boolean;
/** Extra class names applied to the prose wrapper. */
className?: string;
}
// ─── Inline code / pre renderers ─────────────────────────────────────────────
function InlineCode({ children }: { children?: React.ReactNode }) {
return (
<code className="px-1 py-0.5 rounded text-xs font-mono bg-surface-850 text-brand-300 border border-surface-700">
{children}
</code>
);
}
interface PreProps {
children?: React.ReactNode;
}
function Pre({ children }: PreProps) {
return (
<pre className="overflow-x-auto rounded-md bg-surface-900 border border-surface-700 p-3 my-2 text-xs font-mono leading-relaxed">
{children}
</pre>
);
}
// ─── Component ────────────────────────────────────────────────────────────────
export function Markdown({ children, dimColor = false, className }: MarkdownProps) {
// Memoised to avoid re-parsing on every parent render.
const content = useMemo(() => children, [children]);
return (
<div
className={cn(
"markdown-body text-sm leading-relaxed font-mono",
dimColor ? "text-surface-500" : "text-surface-100",
// Headings
"[&_h1]:text-base [&_h1]:font-bold [&_h1]:mb-2 [&_h1]:mt-3 [&_h1]:text-surface-50",
"[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mb-1.5 [&_h2]:mt-2.5 [&_h2]:text-surface-100",
"[&_h3]:text-sm [&_h3]:font-semibold [&_h3]:mb-1 [&_h3]:mt-2 [&_h3]:text-surface-200",
// Paragraphs
"[&_p]:my-1 [&_p]:leading-relaxed",
// Lists
"[&_ul]:my-1 [&_ul]:pl-4 [&_ul]:list-disc",
"[&_ol]:my-1 [&_ol]:pl-4 [&_ol]:list-decimal",
"[&_li]:my-0.5",
// Blockquote
"[&_blockquote]:border-l-2 [&_blockquote]:border-brand-500 [&_blockquote]:pl-3",
"[&_blockquote]:my-2 [&_blockquote]:text-surface-400 [&_blockquote]:italic",
// Horizontal rule
"[&_hr]:border-surface-700 [&_hr]:my-3",
// Tables (GFM)
"[&_table]:w-full [&_table]:text-xs [&_table]:border-collapse [&_table]:my-2",
"[&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:border [&_th]:border-surface-700 [&_th]:bg-surface-800 [&_th]:font-semibold",
"[&_td]:px-2 [&_td]:py-1 [&_td]:border [&_td]:border-surface-700",
"[&_tr:nth-child(even)_td]:bg-surface-900/40",
// Links
"[&_a]:text-brand-400 [&_a]:no-underline [&_a:hover]:underline",
// Strong / em
"[&_strong]:font-bold [&_strong]:text-surface-50",
"[&_em]:italic",
className
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className: cls, children: codeChildren, ...rest }) {
const isBlock = /language-/.test(cls ?? "");
if (isBlock) {
return (
<code className={cn("block text-surface-200", cls)} {...rest}>
{codeChildren}
</code>
);
}
return <InlineCode {...rest}>{codeChildren}</InlineCode>;
},
pre: ({ children: preChildren }) => <Pre>{preChildren}</Pre>,
}}
>
{content}
</ReactMarkdown>
</div>
);
}
// ─── Table component (matches MarkdownTable.tsx surface) ─────────────────────
export interface MarkdownTableProps {
headers: string[];
rows: string[][];
className?: string;
}
export function MarkdownTable({ headers, rows, className }: MarkdownTableProps) {
return (
<div className={cn("overflow-x-auto my-2", className)}>
<table className="w-full text-xs border-collapse font-mono">
<thead>
<tr>
{headers.map((h, i) => (
<th
key={i}
className="px-2 py-1 text-left border border-surface-700 bg-surface-800 font-semibold text-surface-200"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td
key={ci}
className={cn(
"px-2 py-1 border border-surface-700 text-surface-300",
ri % 2 === 1 && "bg-surface-900/40"
)}
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
/**
* Web-adapted Spinner.
*
* The terminal Spinner (src/components/Spinner.tsx) drives animation via
* useAnimationFrame and renders Unicode braille/block characters with ANSI
* colour via Ink's <Text>. In the browser we replace that with a pure-CSS
* spinning ring, preserving the same optional `tip` text and `mode` prop
* surface so callers can swap in this component without changing props.
*/
import * as React from "react";
import { cn } from "@/lib/utils";
// ─── Types ────────────────────────────────────────────────────────────────────
/** Mirrors the SpinnerMode type from src/components/Spinner/index.ts */
export type SpinnerMode =
| "queued"
| "loading"
| "thinking"
| "auto"
| "disabled";
export interface SpinnerProps {
/** Visual mode — controls colour/appearance. */
mode?: SpinnerMode;
/** Optional tip text shown next to the spinner. */
spinnerTip?: string;
/** Override message replaces the default verb. */
overrideMessage?: string | null;
/** Additional suffix appended after the main label. */
spinnerSuffix?: string | null;
/** When true the spinner renders inline instead of as a block row. */
inline?: boolean;
/** Extra class names for the wrapper element. */
className?: string;
}
// ─── Colour map ───────────────────────────────────────────────────────────────
const MODE_RING_CLASS: Record<SpinnerMode, string> = {
queued: "border-surface-500",
loading: "border-brand-400",
thinking: "border-brand-500",
auto: "border-brand-400",
disabled: "border-surface-600",
};
const MODE_TEXT_CLASS: Record<SpinnerMode, string> = {
queued: "text-surface-400",
loading: "text-brand-300",
thinking: "text-brand-300",
auto: "text-brand-300",
disabled: "text-surface-500",
};
const MODE_LABEL: Record<SpinnerMode, string> = {
queued: "Queued…",
loading: "Loading…",
thinking: "Thinking…",
auto: "Working…",
disabled: "",
};
// ─── Component ────────────────────────────────────────────────────────────────
export function Spinner({
mode = "loading",
spinnerTip,
overrideMessage,
spinnerSuffix,
inline = false,
className,
}: SpinnerProps) {
if (mode === "disabled") return null;
const label =
overrideMessage ??
spinnerTip ??
MODE_LABEL[mode];
const ringClass = MODE_RING_CLASS[mode];
const textClass = MODE_TEXT_CLASS[mode];
return (
<span
role="status"
aria-label={label || "Loading"}
className={cn(
"flex items-center gap-2",
inline ? "inline-flex" : "flex",
className
)}
>
{/* CSS spinning ring */}
<span
className={cn(
"block w-3.5 h-3.5 rounded-full border-2 border-transparent animate-spin flex-shrink-0",
ringClass,
// Top border only — creates the "gap" in the ring for the spinning effect
"[border-top-color:currentColor]"
)}
style={{ borderTopColor: undefined }}
aria-hidden
>
{/* Inner ring for the visible arc — achieved via box-shadow trick */}
</span>
{(label || spinnerSuffix) && (
<span className={cn("text-sm font-mono", textClass)}>
{label}
{spinnerSuffix && (
<span className="text-surface-500 ml-1">{spinnerSuffix}</span>
)}
</span>
)}
</span>
);
}
// ─── Shimmer / glimmer variant ────────────────────────────────────────────────
/** Pulsing shimmer bar — web replacement for GlimmerMessage / ShimmerChar. */
export function ShimmerBar({ className }: { className?: string }) {
return (
<div
className={cn(
"h-2 rounded-full bg-gradient-to-r from-surface-700 via-surface-500 to-surface-700",
"bg-[length:200%_100%] animate-shimmer",
className
)}
aria-hidden
/>
);
}
/** Inline flashing cursor dot — web replacement for FlashingChar. */
export function FlashingCursor({ className }: { className?: string }) {
return (
<span
className={cn(
"inline-block w-1.5 h-4 bg-current align-text-bottom ml-0.5",
"animate-pulse-soft",
className
)}
aria-hidden
/>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { Send, Square, Paperclip } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { streamChat } from "@/lib/api";
import { cn } from "@/lib/utils";
import { MAX_MESSAGE_LENGTH } from "@/lib/constants";
interface ChatInputProps {
conversationId: string;
}
export function ChatInput({ conversationId }: ChatInputProps) {
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
const { conversations, settings, addMessage, updateMessage } = useChatStore();
const conversation = conversations.find((c) => c.id === conversationId);
const handleSubmit = useCallback(async () => {
const text = input.trim();
if (!text || isStreaming) return;
setInput("");
setIsStreaming(true);
// Add user message
addMessage(conversationId, {
role: "user",
content: text,
status: "complete",
});
// Add placeholder assistant message
const assistantId = addMessage(conversationId, {
role: "assistant",
content: "",
status: "streaming",
});
const controller = new AbortController();
abortRef.current = controller;
const messages = [
...(conversation?.messages ?? []).map((m) => ({
role: m.role,
content: m.content,
})),
{ role: "user" as const, content: text },
];
let fullText = "";
try {
for await (const chunk of streamChat(messages, settings.model, controller.signal)) {
if (chunk.type === "text" && chunk.content) {
fullText += chunk.content;
updateMessage(conversationId, assistantId, {
content: fullText,
status: "streaming",
});
} else if (chunk.type === "done") {
break;
} else if (chunk.type === "error") {
updateMessage(conversationId, assistantId, {
content: chunk.error ?? "An error occurred",
status: "error",
});
return;
}
}
updateMessage(conversationId, assistantId, { status: "complete" });
} catch (err) {
if ((err as Error).name !== "AbortError") {
updateMessage(conversationId, assistantId, {
content: "Request failed. Please try again.",
status: "error",
});
} else {
updateMessage(conversationId, assistantId, { status: "complete" });
}
} finally {
setIsStreaming(false);
abortRef.current = null;
}
}, [input, isStreaming, conversationId, conversation, settings.model, addMessage, updateMessage]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleStop = () => {
abortRef.current?.abort();
};
const adjustHeight = () => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
};
return (
<div className="border-t border-surface-800 bg-surface-900/50 backdrop-blur-sm px-4 py-3">
<div className="max-w-3xl mx-auto">
<div
className={cn(
"flex items-end gap-2 rounded-xl border bg-surface-800 px-3 py-2",
"border-surface-700 focus-within:border-brand-500 transition-colors"
)}
>
<button
className="p-1 text-surface-500 hover:text-surface-300 transition-colors flex-shrink-0 mb-0.5"
aria-label="Attach file"
>
<Paperclip className="w-4 h-4" aria-hidden="true" />
</button>
<label htmlFor="chat-input" className="sr-only">
Message
</label>
<textarea
id="chat-input"
ref={textareaRef}
value={input}
onChange={(e) => {
setInput(e.target.value.slice(0, MAX_MESSAGE_LENGTH));
adjustHeight();
}}
onKeyDown={handleKeyDown}
placeholder="Message Claude Code..."
rows={1}
aria-label="Message"
className={cn(
"flex-1 resize-none bg-transparent text-sm text-surface-100",
"placeholder:text-surface-500 focus:outline-none",
"min-h-[24px] max-h-[200px] py-0.5"
)}
/>
{isStreaming ? (
<button
onClick={handleStop}
aria-label="Stop generation"
className="p-1.5 rounded-lg bg-surface-700 text-surface-300 hover:bg-surface-600 transition-colors flex-shrink-0"
>
<Square className="w-4 h-4" aria-hidden="true" />
</button>
) : (
<button
onClick={handleSubmit}
disabled={!input.trim()}
aria-label="Send message"
aria-disabled={!input.trim()}
className={cn(
"p-1.5 rounded-lg transition-colors flex-shrink-0",
input.trim()
? "bg-brand-600 text-white hover:bg-brand-700"
: "bg-surface-700 text-surface-500 cursor-not-allowed"
)}
>
<Send className="w-4 h-4" aria-hidden="true" />
</button>
)}
</div>
<p className="text-xs text-surface-600 text-center mt-2">
Claude can make mistakes. Verify important information.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { useEffect } from "react";
import { useChatStore } from "@/lib/store";
import { Sidebar } from "@/components/layout/Sidebar";
import { Header } from "@/components/layout/Header";
import { ChatWindow } from "./ChatWindow";
import { ChatInput } from "./ChatInput";
import { SkipToContent } from "@/components/a11y/SkipToContent";
import { AnnouncerProvider } from "@/components/a11y/Announcer";
export function ChatLayout() {
const { conversations, createConversation, activeConversationId } = useChatStore();
useEffect(() => {
if (conversations.length === 0) {
createConversation();
}
}, []);
return (
<AnnouncerProvider>
<SkipToContent />
<div className="flex h-screen bg-surface-950 text-surface-100">
<Sidebar />
<div className="flex flex-col flex-1 min-w-0">
<Header />
<main
id="main-content"
aria-label="Chat"
className="flex flex-col flex-1 min-h-0"
>
{activeConversationId ? (
<>
<ChatWindow conversationId={activeConversationId} />
<ChatInput conversationId={activeConversationId} />
</>
) : (
<div className="flex-1 flex items-center justify-center text-surface-500">
Select or create a conversation
</div>
)}
</main>
</div>
</div>
</AnnouncerProvider>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useChatStore } from "@/lib/store";
import { MessageBubble } from "./MessageBubble";
import { Bot } from "lucide-react";
interface ChatWindowProps {
conversationId: string;
}
export function ChatWindow({ conversationId }: ChatWindowProps) {
const bottomRef = useRef<HTMLDivElement>(null);
const { conversations } = useChatStore();
const conversation = conversations.find((c) => c.id === conversationId);
const messages = conversation?.messages ?? [];
const isStreaming = messages.some((m) => m.status === "streaming");
// Announce the last completed assistant message to screen readers
const [announcement, setAnnouncement] = useState("");
const prevLengthRef = useRef(messages.length);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
const lastMsg = messages[messages.length - 1];
if (
messages.length > prevLengthRef.current &&
lastMsg?.role === "assistant" &&
lastMsg.status === "complete"
) {
// Announce a short preview so screen reader users know a reply arrived
const preview = lastMsg.content.slice(0, 100);
setAnnouncement("");
setTimeout(() => setAnnouncement(`Claude replied: ${preview}`), 50);
}
prevLengthRef.current = messages.length;
}, [messages.length, messages]);
if (messages.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-center px-6">
<div
className="w-12 h-12 rounded-full bg-brand-600/20 flex items-center justify-center"
aria-hidden="true"
>
<Bot className="w-6 h-6 text-brand-400" aria-hidden="true" />
</div>
<div>
<h2 className="text-lg font-semibold text-surface-100">How can I help?</h2>
<p className="text-sm text-surface-400 mt-1">
Start a conversation with Claude Code
</p>
</div>
</div>
);
}
return (
<div
className="flex-1 overflow-y-auto"
aria-busy={isStreaming}
aria-label="Conversation"
>
{/* Polite live region — announces when Claude finishes a reply */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
style={{
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0,0,0,0)",
whiteSpace: "nowrap",
borderWidth: 0,
}}
>
{announcement}
</div>
<div className="max-w-3xl mx-auto py-6 px-4 space-y-6">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
<div ref={bottomRef} aria-hidden="true" />
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils";
interface MarkdownContentProps {
content: string;
className?: string;
}
export function MarkdownContent({ content, className }: MarkdownContentProps) {
return (
<div
className={cn(
"prose prose-sm prose-invert max-w-none",
"prose-p:leading-relaxed prose-p:my-1",
"prose-pre:bg-surface-900 prose-pre:border prose-pre:border-surface-700",
"prose-code:text-brand-300 prose-code:bg-surface-900 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs",
"prose-pre:code:bg-transparent prose-pre:code:text-surface-100 prose-pre:code:p-0",
"prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5",
"prose-headings:text-surface-100 prose-headings:font-semibold",
"prose-a:text-brand-400 prose-a:no-underline hover:prose-a:underline",
"prose-blockquote:border-brand-500 prose-blockquote:text-surface-300",
"prose-hr:border-surface-700",
className
)}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { User, Bot, AlertCircle } from "lucide-react";
import { cn, extractTextContent } from "@/lib/utils";
import type { Message } from "@/lib/types";
import { MarkdownContent } from "./MarkdownContent";
interface MessageBubbleProps {
message: Message;
}
export function MessageBubble({ message }: MessageBubbleProps) {
const isUser = message.role === "user";
const isError = message.status === "error";
const text = extractTextContent(message.content);
return (
<article
className={cn(
"flex gap-3 animate-fade-in",
isUser && "flex-row-reverse"
)}
aria-label={isUser ? "You" : isError ? "Error from Claude" : "Claude"}
>
{/* Avatar — purely decorative, role conveyed by article label */}
<div
aria-hidden="true"
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5",
isUser
? "bg-brand-600 text-white"
: isError
? "bg-red-900 text-red-300"
: "bg-surface-700 text-surface-300"
)}
>
{isUser ? (
<User className="w-4 h-4" aria-hidden="true" />
) : isError ? (
<AlertCircle className="w-4 h-4" aria-hidden="true" />
) : (
<Bot className="w-4 h-4" aria-hidden="true" />
)}
</div>
{/* Content */}
<div
className={cn(
"flex-1 min-w-0 max-w-2xl",
isUser && "flex justify-end"
)}
>
<div
className={cn(
"rounded-2xl px-4 py-3 text-sm",
isUser
? "bg-brand-600 text-white rounded-tr-sm"
: isError
? "bg-red-950 border border-red-800 text-red-200 rounded-tl-sm"
: "bg-surface-800 text-surface-100 rounded-tl-sm"
)}
>
{isUser ? (
<p className="whitespace-pre-wrap break-words">{text}</p>
) : (
<MarkdownContent content={text} />
)}
{message.status === "streaming" && (
<span
aria-hidden="true"
className="inline-block w-1.5 h-4 bg-current ml-0.5 animate-pulse-soft"
/>
)}
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { useRef, useEffect, useCallback } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { Message } from "@/lib/types";
import { MessageBubble } from "./MessageBubble";
/**
* Estimated heights used for initial layout. The virtualizer measures actual
* heights after render and updates scroll positions accordingly.
*/
const ESTIMATED_HEIGHT = {
short: 80, // typical user message
medium: 160, // short assistant reply
tall: 320, // code blocks / long replies
};
function estimateMessageHeight(message: Message): number {
const text =
typeof message.content === "string"
? message.content
: message.content
.filter((b): b is { type: "text"; text: string } => b.type === "text")
.map((b) => b.text)
.join("");
if (text.length < 100) return ESTIMATED_HEIGHT.short;
if (text.length < 500 || text.includes("```")) return ESTIMATED_HEIGHT.medium;
return ESTIMATED_HEIGHT.tall;
}
interface VirtualMessageListProps {
messages: Message[];
/** Whether streaming is in progress — suppresses smooth-scroll so the
* autoscroll keeps up with incoming tokens. */
isStreaming: boolean;
}
export function VirtualMessageList({ messages, isStreaming }: VirtualMessageListProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => scrollRef.current,
estimateSize: (index) => estimateMessageHeight(messages[index]),
overscan: 5,
});
// Track whether the user has scrolled away from the bottom
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
isAtBottomRef.current = distanceFromBottom < 80;
}, []);
// Auto-scroll to bottom when new messages arrive (if already at bottom)
useEffect(() => {
if (!isAtBottomRef.current) return;
const el = scrollRef.current;
if (!el) return;
if (isStreaming) {
// Instant scroll during streaming to keep up with tokens
el.scrollTop = el.scrollHeight;
} else {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}
}, [messages.length, isStreaming]);
// Also scroll when the last streaming message content changes
useEffect(() => {
if (!isStreaming || !isAtBottomRef.current) return;
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
const items = virtualizer.getVirtualItems();
return (
<div
ref={scrollRef}
className="flex-1 overflow-y-auto"
onScroll={handleScroll}
>
{/* Spacer that gives the virtualizer its total height */}
<div
style={{ height: virtualizer.getTotalSize(), position: "relative" }}
className="max-w-3xl mx-auto px-4 py-6"
>
{items.map((virtualItem) => {
const message = messages[virtualItem.index];
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
transform: `translateY(${virtualItem.start}px)`,
}}
className="pb-6"
>
<MessageBubble message={message} />
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { MessageSquare } from "lucide-react";
import { cn } from "@/lib/utils";
import { AnnotationThread } from "./AnnotationThread";
import { useCollaborationContextOptional } from "./CollaborationProvider";
interface AnnotationBadgeProps {
messageId: string;
}
export function AnnotationBadge({ messageId }: AnnotationBadgeProps) {
const ctx = useCollaborationContextOptional();
const [open, setOpen] = useState(false);
if (!ctx) return null;
const annotations = ctx.annotations[messageId] ?? [];
const unresolved = annotations.filter((a) => !a.resolved);
if (annotations.length === 0) return null;
return (
<div className="relative inline-block">
<button
onClick={() => setOpen((v) => !v)}
className={cn(
"flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium",
"transition-colors border",
unresolved.length > 0
? "bg-amber-900/30 border-amber-700/50 text-amber-300 hover:bg-amber-900/50"
: "bg-surface-800 border-surface-700 text-surface-400 hover:bg-surface-700"
)}
title={`${annotations.length} comment${annotations.length !== 1 ? "s" : ""}`}
>
<MessageSquare className="w-3 h-3" />
{unresolved.length > 0 ? unresolved.length : annotations.length}
</button>
{open && (
<div
className="absolute right-0 top-full mt-1 z-40 w-80"
onKeyDown={(e) => e.key === "Escape" && setOpen(false)}
>
<AnnotationThread messageId={messageId} onClose={() => setOpen(false)} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { createContext, useContext, useRef, useMemo } from "react";
import { useCollaboration } from "@/hooks/useCollaboration";
import { usePresence } from "@/hooks/usePresence";
import { CollabSocket } from "@/lib/collaboration/socket";
import type { CollabUser, CollabRole } from "@/lib/collaboration/socket";
import type { CollabAnnotation, PendingToolUse } from "@/lib/collaboration/types";
import type { PresenceState } from "@/lib/collaboration/presence";
import type { LinkExpiry, ShareLink } from "@/lib/collaboration/types";
import { createShareLink } from "@/lib/collaboration/permissions";
// ─── Context Shape ────────────────────────────────────────────────────────────
interface CollaborationContextValue {
// Connection
isConnected: boolean;
sessionId: string;
currentUser: CollabUser;
// Roles & policy
myRole: CollabRole | null;
toolApprovalPolicy: "owner-only" | "any-collaborator";
// Presence
presence: PresenceState;
otherUsers: CollabUser[];
typingUsers: CollabUser[];
// Tool approvals
pendingToolUses: PendingToolUse[];
approveTool: (toolUseId: string) => void;
denyTool: (toolUseId: string) => void;
// Annotations
annotations: Record<string, CollabAnnotation[]>;
addAnnotation: (messageId: string, text: string) => void;
resolveAnnotation: (annotationId: string, resolved: boolean) => void;
replyAnnotation: (annotationId: string, text: string) => void;
// Presence actions
sendCursorUpdate: (pos: number, start?: number, end?: number) => void;
notifyTyping: () => void;
stopTyping: () => void;
// Session management
generateShareLink: (role: CollabRole, expiry: LinkExpiry) => ShareLink;
revokeAccess: (userId: string) => void;
changeRole: (userId: string, role: CollabRole) => void;
transferOwnership: (userId: string) => void;
}
const CollaborationContext = createContext<CollaborationContextValue | null>(null);
// ─── Provider ─────────────────────────────────────────────────────────────────
interface CollaborationProviderProps {
sessionId: string;
currentUser: CollabUser;
wsUrl?: string;
children: React.ReactNode;
}
export function CollaborationProvider({
sessionId,
currentUser,
wsUrl,
children,
}: CollaborationProviderProps) {
const socketRef = useRef<CollabSocket | null>(null);
const collab = useCollaboration({ sessionId, currentUser, wsUrl });
// Access the socket from the ref (set by the hook internally)
// Since useCollaboration creates the socket internally, we expose a proxy
// via the presence hook's socket param by reaching into the hook return
const presence = usePresence({
socket: socketRef.current,
sessionId,
currentUser,
});
const generateShareLink = useMemo(
() => (role: CollabRole, expiry: LinkExpiry) =>
createShareLink(sessionId, role, expiry, currentUser.id),
[sessionId, currentUser.id]
);
const value: CollaborationContextValue = {
isConnected: collab.isConnected,
sessionId,
currentUser,
myRole: collab.myRole,
toolApprovalPolicy: collab.toolApprovalPolicy,
presence: presence.presence,
otherUsers: presence.otherUsers,
typingUsers: presence.typingUsers,
pendingToolUses: collab.pendingToolUses,
approveTool: collab.approveTool,
denyTool: collab.denyTool,
annotations: collab.annotations,
addAnnotation: collab.addAnnotation,
resolveAnnotation: collab.resolveAnnotation,
replyAnnotation: collab.replyAnnotation,
sendCursorUpdate: presence.sendCursorUpdate,
notifyTyping: presence.notifyTyping,
stopTyping: presence.stopTyping,
generateShareLink,
revokeAccess: collab.revokeAccess,
changeRole: collab.changeRole,
transferOwnership: collab.transferOwnership,
};
return (
<CollaborationContext.Provider value={value}>
{children}
</CollaborationContext.Provider>
);
}
// ─── Consumer Hook ────────────────────────────────────────────────────────────
export function useCollaborationContext(): CollaborationContextValue {
const ctx = useContext(CollaborationContext);
if (!ctx) {
throw new Error(
"useCollaborationContext must be used inside <CollaborationProvider>"
);
}
return ctx;
}
/**
* Returns null when there is no active collaboration session.
* Use this in components that render outside a CollaborationProvider.
*/
export function useCollaborationContextOptional(): CollaborationContextValue | null {
return useContext(CollaborationContext);
}

View File

@@ -0,0 +1,122 @@
"use client";
import { useRef, useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useCollaborationContextOptional } from "./CollaborationProvider";
import type { CursorState } from "@/lib/collaboration/presence";
import type { CollabUser } from "@/lib/collaboration/socket";
// ─── Types ────────────────────────────────────────────────────────────────────
interface CursorGhostProps {
/** The textarea ref to measure cursor positions against */
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
}
interface RenderedCursor {
user: CollabUser;
cursor: CursorState;
top: number;
left: number;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Approximates pixel position of a text offset inside a textarea.
* Uses a hidden mirror div that matches the textarea's styling.
*/
function measureCursorPosition(
textarea: HTMLTextAreaElement,
offset: number
): { top: number; left: number } {
const mirror = document.createElement("div");
const computed = window.getComputedStyle(textarea);
mirror.style.position = "absolute";
mirror.style.visibility = "hidden";
mirror.style.whiteSpace = "pre-wrap";
mirror.style.wordWrap = "break-word";
mirror.style.width = computed.width;
mirror.style.font = computed.font;
mirror.style.lineHeight = computed.lineHeight;
mirror.style.padding = computed.padding;
mirror.style.border = computed.border;
mirror.style.boxSizing = computed.boxSizing;
const text = textarea.value.slice(0, offset);
mirror.textContent = text;
const span = document.createElement("span");
span.textContent = "\u200b"; // zero-width space
mirror.appendChild(span);
document.body.appendChild(mirror);
const rect = textarea.getBoundingClientRect();
const spanRect = span.getBoundingClientRect();
document.body.removeChild(mirror);
return {
top: spanRect.top - rect.top + textarea.scrollTop,
left: spanRect.left - rect.left,
};
}
// ─── CursorGhost ─────────────────────────────────────────────────────────────
export function CursorGhost({ textareaRef }: CursorGhostProps) {
const ctx = useCollaborationContextOptional();
const [rendered, setRendered] = useState<RenderedCursor[]>([]);
useEffect(() => {
if (!ctx || !textareaRef.current) return;
const textarea = textareaRef.current;
const { presence, otherUsers } = ctx;
const next: RenderedCursor[] = [];
for (const user of otherUsers) {
const cursor = presence.cursors.get(user.id);
if (!cursor) continue;
try {
const pos = measureCursorPosition(textarea, cursor.position);
next.push({ user, cursor, ...pos });
} catch {
// ignore measurement errors
}
}
setRendered(next);
});
if (!ctx || rendered.length === 0) return null;
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<AnimatePresence>
{rendered.map(({ user, top, left }) => (
<motion.div
key={user.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute flex flex-col items-start"
style={{ top, left }}
>
{/* Cursor caret */}
<div
className="w-0.5 h-4"
style={{ backgroundColor: user.color }}
/>
{/* Name tag */}
<div
className="px-1 py-0.5 rounded text-[9px] font-semibold text-white whitespace-nowrap"
style={{ backgroundColor: user.color }}
>
{user.name}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,136 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import * as Tooltip from "@radix-ui/react-tooltip";
import { Wifi, WifiOff } from "lucide-react";
import { getInitials } from "@/lib/collaboration/presence";
import { labelForRole } from "@/lib/collaboration/permissions";
import { useCollaborationContextOptional } from "./CollaborationProvider";
import { cn } from "@/lib/utils";
// ─── Single Avatar ────────────────────────────────────────────────────────────
interface AvatarProps {
name: string;
color: string;
avatar?: string;
role: import("@/lib/collaboration/socket").CollabRole;
isActive?: boolean;
}
function UserAvatar({ name, color, avatar, role, isActive = true }: AvatarProps) {
return (
<Tooltip.Provider delayDuration={300}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div
className="relative w-7 h-7 rounded-full flex-shrink-0 cursor-default select-none"
style={{ boxShadow: `0 0 0 2px ${color}` }}
>
{avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatar}
alt={name}
className="w-full h-full rounded-full object-cover"
/>
) : (
<div
className="w-full h-full rounded-full flex items-center justify-center text-[10px] font-semibold text-white"
style={{ backgroundColor: color }}
>
{getInitials(name)}
</div>
)}
{/* Online indicator dot */}
{isActive && (
<span className="absolute bottom-0 right-0 w-2 h-2 rounded-full bg-green-400 border border-surface-900" />
)}
</div>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="bottom"
sideOffset={6}
className={cn(
"z-50 rounded-md px-2.5 py-1.5 text-xs shadow-md",
"bg-surface-800 border border-surface-700 text-surface-100"
)}
>
<p className="font-medium">{name}</p>
<p className="text-surface-400">{labelForRole(role)}</p>
<Tooltip.Arrow className="fill-surface-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
// ─── PresenceAvatars ──────────────────────────────────────────────────────────
export function PresenceAvatars() {
const ctx = useCollaborationContextOptional();
if (!ctx) return null;
const { isConnected, otherUsers, currentUser } = ctx;
// Show at most 4 avatars + overflow badge
const MAX_VISIBLE = 4;
const allUsers = [currentUser, ...otherUsers];
const visible = allUsers.slice(0, MAX_VISIBLE);
const overflow = allUsers.length - MAX_VISIBLE;
return (
<div className="flex items-center gap-2">
{/* Connection indicator */}
<div className="flex items-center gap-1.5">
{isConnected ? (
<Wifi className="w-3.5 h-3.5 text-green-400" />
) : (
<WifiOff className="w-3.5 h-3.5 text-surface-500 animate-pulse" />
)}
<span className="text-xs text-surface-500 hidden sm:inline">
{isConnected
? `${allUsers.length} online`
: "Reconnecting…"}
</span>
</div>
{/* Stacked avatars */}
<div className="flex items-center">
<AnimatePresence>
{visible.map((user, i) => (
<motion.div
key={user.id}
initial={{ opacity: 0, scale: 0.5, x: -8 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.2, delay: i * 0.04 }}
style={{ zIndex: visible.length - i, marginLeft: i === 0 ? 0 : -8 }}
>
<UserAvatar
name={user.id === currentUser.id ? `${user.name} (you)` : user.name}
color={user.color}
avatar={user.avatar}
role={user.role}
/>
</motion.div>
))}
</AnimatePresence>
{overflow > 0 && (
<div
className={cn(
"w-7 h-7 rounded-full -ml-2 z-0 flex items-center justify-center",
"bg-surface-700 border-2 border-surface-900 text-[10px] font-medium text-surface-300"
)}
>
+{overflow}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { useCollaborationContextOptional } from "./CollaborationProvider";
// ─── Animated dots ────────────────────────────────────────────────────────────
function Dots() {
return (
<span className="inline-flex items-end gap-0.5 h-3">
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="w-1 h-1 rounded-full bg-surface-400 inline-block"
animate={{ y: [0, -3, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: i * 0.15,
ease: "easeInOut",
}}
/>
))}
</span>
);
}
// ─── TypingIndicator ──────────────────────────────────────────────────────────
export function TypingIndicator() {
const ctx = useCollaborationContextOptional();
if (!ctx) return null;
const { typingUsers } = ctx;
if (typingUsers.length === 0) return null;
let label: string;
if (typingUsers.length === 1) {
label = `${typingUsers[0].name} is typing`;
} else if (typingUsers.length === 2) {
label = `${typingUsers[0].name} and ${typingUsers[1].name} are typing`;
} else {
label = `${typingUsers.length} people are typing`;
}
return (
<AnimatePresence>
<motion.div
key="typing-indicator"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.15 }}
className="flex items-center gap-1.5 px-4 pb-1 text-xs text-surface-400"
>
{/* Colored dots for each typing user */}
<span className="flex -space-x-1">
{typingUsers.slice(0, 3).map((u) => (
<span
key={u.id}
className="w-4 h-4 rounded-full border border-surface-900 flex items-center justify-center text-[8px] font-bold text-white"
style={{ backgroundColor: u.color }}
>
{u.name[0].toUpperCase()}
</span>
))}
</span>
<span>{label}</span>
<Dots />
</motion.div>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,249 @@
"use client";
import { useEffect, useRef, useState, useMemo } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { Search, Clock } from "lucide-react";
import { useCommandRegistry } from "@/hooks/useCommandRegistry";
import { CommandPaletteItem } from "./CommandPaletteItem";
import { SHORTCUT_CATEGORIES } from "@/lib/shortcuts";
import type { Command, ShortcutCategory } from "@/lib/shortcuts";
import { cn } from "@/lib/utils";
/** Fuzzy match: every character of query must appear in order in target */
function fuzzyMatch(target: string, query: string): boolean {
if (!query) return true;
const t = target.toLowerCase();
const q = query.toLowerCase();
let qi = 0;
for (let i = 0; i < t.length && qi < q.length; i++) {
if (t[i] === q[qi]) qi++;
}
return qi === q.length;
}
/** Score a command against a search query (higher = better) */
function score(cmd: Command, query: string): number {
const q = query.toLowerCase();
const label = cmd.label.toLowerCase();
if (label === q) return 100;
if (label.startsWith(q)) return 80;
if (label.includes(q)) return 60;
if (cmd.description.toLowerCase().includes(q)) return 40;
if (fuzzyMatch(label, q)) return 20;
return 0;
}
interface GroupedResults {
label: string;
commands: Command[];
}
export function CommandPalette() {
const {
paletteOpen,
closePalette,
commands,
runCommand,
recentCommandIds,
openHelp,
} = useCommandRegistry();
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Reset state when palette opens
useEffect(() => {
if (paletteOpen) {
setQuery("");
setActiveIndex(0);
// Small delay to let the dialog animate in before focusing
setTimeout(() => inputRef.current?.focus(), 10);
}
}, [paletteOpen]);
const filteredGroups = useMemo<GroupedResults[]>(() => {
if (!query.trim()) {
// Show recents first, then all categories
const recentCmds = recentCommandIds
.map((id) => commands.find((c) => c.id === id))
.filter((c): c is Command => !!c);
const groups: GroupedResults[] = [];
if (recentCmds.length > 0) {
groups.push({ label: "Recent", commands: recentCmds });
}
for (const cat of SHORTCUT_CATEGORIES) {
const catCmds = commands.filter((c) => c.category === cat);
if (catCmds.length > 0) {
groups.push({ label: cat, commands: catCmds });
}
}
return groups;
}
// Search mode: flat scored list, re-grouped by category
const scored = commands
.map((cmd) => ({ cmd, s: score(cmd, query) }))
.filter(({ s }) => s > 0)
.sort((a, b) => b.s - a.s)
.map(({ cmd }) => cmd);
if (scored.length === 0) return [];
const byCategory: Partial<Record<ShortcutCategory, Command[]>> = {};
for (const cmd of scored) {
if (!byCategory[cmd.category]) byCategory[cmd.category] = [];
byCategory[cmd.category]!.push(cmd);
}
return SHORTCUT_CATEGORIES.filter((c) => byCategory[c]?.length).map(
(c) => ({ label: c, commands: byCategory[c]! })
);
}, [query, commands, recentCommandIds]);
const flatResults = useMemo(
() => filteredGroups.flatMap((g) => g.commands),
[filteredGroups]
);
// Clamp activeIndex when results change
useEffect(() => {
setActiveIndex((i) => Math.min(i, Math.max(flatResults.length - 1, 0)));
}, [flatResults.length]);
// Scroll active item into view
useEffect(() => {
const el = listRef.current?.querySelector(`[data-index="${activeIndex}"]`);
el?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, flatResults.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const cmd = flatResults[activeIndex];
if (cmd) {
closePalette();
runCommand(cmd.id);
}
}
};
const handleSelect = (cmd: Command) => {
closePalette();
runCommand(cmd.id);
};
let flatIdx = 0;
return (
<Dialog.Root open={paletteOpen} onOpenChange={(open) => !open && closePalette()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<Dialog.Content
className={cn(
"fixed left-1/2 top-[20%] -translate-x-1/2 z-50",
"w-full max-w-xl",
"bg-surface-900 border border-surface-700 rounded-xl shadow-2xl",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[state=closed]:slide-out-to-left-1/2 data-[state=open]:slide-in-from-left-1/2",
"data-[state=closed]:slide-out-to-top-[18%] data-[state=open]:slide-in-from-top-[18%]"
)}
onKeyDown={handleKeyDown}
aria-label="Command palette"
>
{/* Search input */}
<div className="flex items-center gap-2 px-3 py-3 border-b border-surface-800">
<Search className="w-4 h-4 text-surface-500 flex-shrink-0" />
<input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActiveIndex(0);
}}
placeholder="Search commands..."
className={cn(
"flex-1 bg-transparent text-sm text-surface-100",
"placeholder:text-surface-500 focus:outline-none"
)}
/>
<kbd className="hidden sm:inline-flex items-center h-5 px-1.5 rounded text-[10px] font-mono bg-surface-800 border border-surface-700 text-surface-500">
Esc
</kbd>
</div>
{/* Results */}
<div
ref={listRef}
role="listbox"
className="overflow-y-auto max-h-[360px] py-1"
>
{filteredGroups.length === 0 ? (
<div className="py-10 text-center text-sm text-surface-500">
No commands found
</div>
) : (
filteredGroups.map((group) => (
<div key={group.label}>
<div className="flex items-center gap-2 px-3 py-1.5">
{group.label === "Recent" && (
<Clock className="w-3 h-3 text-surface-600" />
)}
<span className="text-[10px] font-semibold uppercase tracking-wider text-surface-600">
{group.label}
</span>
</div>
{group.commands.map((cmd) => {
const idx = flatIdx++;
return (
<div key={cmd.id} data-index={idx}>
<CommandPaletteItem
command={cmd}
isActive={idx === activeIndex}
onSelect={() => handleSelect(cmd)}
onHighlight={() => setActiveIndex(idx)}
/>
</div>
);
})}
</div>
))
)}
</div>
{/* Footer */}
<div className="flex items-center gap-4 px-3 py-2 border-t border-surface-800 text-[10px] text-surface-600">
<span className="flex items-center gap-1">
<kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 font-mono"></kbd>
navigate
</span>
<span className="flex items-center gap-1">
<kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 font-mono"></kbd>
select
</span>
<span className="flex items-center gap-1">
<kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 font-mono">Esc</kbd>
close
</span>
<button
onClick={() => { closePalette(); openHelp(); }}
className="ml-auto hover:text-surface-300 transition-colors"
>
? View all shortcuts
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import {
MessageSquarePlus,
Trash2,
Settings,
Sun,
Search,
HelpCircle,
PanelLeftClose,
ChevronRight,
Zap,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { ShortcutBadge } from "@/components/shortcuts/ShortcutBadge";
import type { Command } from "@/lib/shortcuts";
const ICON_MAP: Record<string, LucideIcon> = {
MessageSquarePlus,
Trash2,
Settings,
Sun,
Search,
HelpCircle,
PanelLeftClose,
ChevronRight,
Zap,
};
interface CommandPaletteItemProps {
command: Command;
isActive: boolean;
onSelect: () => void;
onHighlight: () => void;
}
export function CommandPaletteItem({
command,
isActive,
onSelect,
onHighlight,
}: CommandPaletteItemProps) {
const Icon = command.icon ? (ICON_MAP[command.icon] ?? ChevronRight) : ChevronRight;
return (
<div
role="option"
aria-selected={isActive}
onClick={onSelect}
onMouseEnter={onHighlight}
className={cn(
"flex items-center gap-3 px-3 py-2.5 cursor-pointer select-none",
"transition-colors",
isActive ? "bg-brand-600/20 text-surface-100" : "text-surface-300 hover:bg-surface-800"
)}
>
<span
className={cn(
"flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center",
isActive ? "bg-brand-600/30 text-brand-400" : "bg-surface-800 text-surface-500"
)}
>
<Icon className="w-3.5 h-3.5" />
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{command.label}</p>
{command.description && (
<p className="text-xs text-surface-500 truncate">{command.description}</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span
className={cn(
"text-[10px] px-1.5 py-0.5 rounded border font-medium",
isActive
? "border-brand-600/40 text-brand-400 bg-brand-600/10"
: "border-surface-700 text-surface-600 bg-surface-800"
)}
>
{command.category}
</span>
{command.keys.length > 0 && <ShortcutBadge keys={command.keys} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
import * as Switch from "@radix-ui/react-switch";
import type { ExportOptions, ExportFormat } from "@/lib/types";
import { cn } from "@/lib/utils";
interface OptionRowProps {
id: string;
label: string;
description?: string;
checked: boolean;
onCheckedChange: (v: boolean) => void;
disabled?: boolean;
}
function OptionRow({ id, label, description, checked, onCheckedChange, disabled }: OptionRowProps) {
return (
<div className={cn("flex items-center justify-between gap-4 py-2", disabled && "opacity-40")}>
<label htmlFor={id} className={cn("flex flex-col gap-0.5", !disabled && "cursor-pointer")}>
<span className="text-sm text-surface-200">{label}</span>
{description && (
<span className="text-xs text-surface-500">{description}</span>
)}
</label>
<Switch.Root
id={id}
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
className={cn(
"w-9 h-5 rounded-full transition-colors outline-none cursor-pointer",
"data-[state=checked]:bg-brand-600 data-[state=unchecked]:bg-surface-700",
"disabled:cursor-not-allowed"
)}
>
<Switch.Thumb className="block w-4 h-4 bg-white rounded-full shadow transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5" />
</Switch.Root>
</div>
);
}
interface ExportOptionsProps {
options: ExportOptions;
onChange: (opts: Partial<ExportOptions>) => void;
}
export function ExportOptionsPanel({ options, onChange }: ExportOptionsProps) {
const isJson = options.format === "json";
return (
<div className="divide-y divide-surface-800">
<OptionRow
id="opt-tool-use"
label="Include tool use"
description="Show tool calls and results in the export"
checked={options.includeToolUse}
onCheckedChange={(v) => onChange({ includeToolUse: v })}
/>
<OptionRow
id="opt-thinking"
label="Include thinking blocks"
description="Show extended thinking when present"
checked={options.includeThinking}
onCheckedChange={(v) => onChange({ includeThinking: v })}
disabled={isJson}
/>
<OptionRow
id="opt-timestamps"
label="Include timestamps"
description="Add date/time to messages and metadata"
checked={options.includeTimestamps}
onCheckedChange={(v) => onChange({ includeTimestamps: v })}
/>
<OptionRow
id="opt-file-contents"
label="Include full file contents"
description="Show complete tool result output (may be large)"
checked={options.includeFileContents}
onCheckedChange={(v) => onChange({ includeFileContents: v })}
/>
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { FileText, Braces, Globe, FileDown, AlignLeft } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ExportFormat } from "@/lib/types";
interface FormatOption {
value: ExportFormat;
label: string;
description: string;
icon: React.ReactNode;
}
const FORMATS: FormatOption[] = [
{
value: "markdown",
label: "Markdown",
description: "Clean .md with code blocks and metadata",
icon: <FileText className="w-4 h-4" />,
},
{
value: "json",
label: "JSON",
description: "Full conversation data with tool use",
icon: <Braces className="w-4 h-4" />,
},
{
value: "html",
label: "HTML",
description: "Self-contained file with embedded styles",
icon: <Globe className="w-4 h-4" />,
},
{
value: "pdf",
label: "PDF",
description: "Print-to-PDF via browser dialog",
icon: <FileDown className="w-4 h-4" />,
},
{
value: "plaintext",
label: "Plain Text",
description: "Stripped of all formatting",
icon: <AlignLeft className="w-4 h-4" />,
},
];
interface FormatSelectorProps {
value: ExportFormat;
onChange: (format: ExportFormat) => void;
}
export function FormatSelector({ value, onChange }: FormatSelectorProps) {
return (
<div className="grid grid-cols-5 gap-2">
{FORMATS.map((fmt) => (
<button
key={fmt.value}
onClick={() => onChange(fmt.value)}
className={cn(
"flex flex-col items-center gap-1.5 px-2 py-3 rounded-lg border text-center transition-colors",
value === fmt.value
? "border-brand-500 bg-brand-500/10 text-brand-300"
: "border-surface-700 bg-surface-800 text-surface-400 hover:border-surface-600 hover:text-surface-200"
)}
title={fmt.description}
>
{fmt.icon}
<span className="text-xs font-medium leading-none">{fmt.label}</span>
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { Copy, Check, ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
interface FileBreadcrumbProps {
path: string;
className?: string;
}
export function FileBreadcrumb({ path, className }: FileBreadcrumbProps) {
const [copied, setCopied] = useState(false);
const segments = path.split("/").filter(Boolean);
const isAbsolute = path.startsWith("/");
const handleCopy = async () => {
await navigator.clipboard.writeText(path);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const openInVSCode = () => {
window.open(`vscode://file${isAbsolute ? path : `/${path}`}`);
};
return (
<div
className={cn(
"flex items-center gap-1 px-3 py-1.5 border-b border-surface-800 bg-surface-900/80",
"text-xs text-surface-400 min-w-0",
className
)}
>
{/* Segments */}
<div className="flex items-center gap-0.5 flex-1 min-w-0 overflow-hidden">
{isAbsolute && (
<span className="text-surface-600 flex-shrink-0">/</span>
)}
{segments.map((seg, i) => (
<span key={i} className="flex items-center gap-0.5 flex-shrink-0">
{i > 0 && <span className="text-surface-700 mx-0.5">/</span>}
<span
className={cn(
"truncate max-w-[120px]",
i === segments.length - 1
? "text-surface-200 font-medium"
: "text-surface-500 hover:text-surface-300 cursor-pointer transition-colors"
)}
title={seg}
>
{seg}
</span>
</span>
))}
</div>
{/* Actions */}
<div className="flex items-center gap-0.5 flex-shrink-0">
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-surface-800 transition-colors"
title="Copy path"
>
{copied ? (
<Check className="w-3 h-3 text-green-400" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
<button
onClick={openInVSCode}
className="p-1 rounded hover:bg-surface-800 transition-colors"
title="Open in VS Code"
>
<ExternalLink className="w-3 h-3" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { Eye, Edit3, GitCompare } from "lucide-react";
import { useFileViewerStore, type FileTab, type FileViewMode } from "@/lib/fileViewerStore";
import { cn } from "@/lib/utils";
interface FileInfoBarProps {
tab: FileTab;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const LANGUAGE_LABELS: Record<string, string> = {
typescript: "TypeScript",
tsx: "TSX",
javascript: "JavaScript",
jsx: "JSX",
python: "Python",
rust: "Rust",
go: "Go",
css: "CSS",
scss: "SCSS",
html: "HTML",
json: "JSON",
markdown: "Markdown",
bash: "Bash",
yaml: "YAML",
toml: "TOML",
sql: "SQL",
graphql: "GraphQL",
ruby: "Ruby",
java: "Java",
c: "C",
cpp: "C++",
csharp: "C#",
php: "PHP",
swift: "Swift",
kotlin: "Kotlin",
dockerfile: "Dockerfile",
makefile: "Makefile",
text: "Plain Text",
};
const VIEW_MODES: { mode: FileViewMode; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ mode: "view", label: "View", icon: Eye },
{ mode: "edit", label: "Edit", icon: Edit3 },
{ mode: "diff", label: "Diff", icon: GitCompare },
];
export function FileInfoBar({ tab }: FileInfoBarProps) {
const { setMode } = useFileViewerStore();
const lineCount = tab.content.split("\n").length;
const byteSize = new TextEncoder().encode(tab.content).length;
const langLabel = LANGUAGE_LABELS[tab.language] ?? tab.language;
return (
<div className="flex items-center justify-between px-3 py-1 border-t border-surface-800 bg-surface-950 text-xs text-surface-500">
{/* Left: file stats */}
<div className="flex items-center gap-3">
<span className="text-surface-400">{langLabel}</span>
<span>UTF-8</span>
<span>{lineCount.toLocaleString()} lines</span>
<span>{formatBytes(byteSize)}</span>
{tab.isDirty && (
<span className="text-yellow-500"> Unsaved changes</span>
)}
</div>
{/* Right: mode switcher */}
{!tab.isImage && (
<div className="flex items-center gap-0.5 bg-surface-900 rounded px-1 py-0.5">
{VIEW_MODES.map(({ mode, label, icon: Icon }) => (
<button
key={mode}
onClick={() => setMode(tab.id, mode)}
className={cn(
"flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors",
tab.mode === mode
? "bg-surface-700 text-surface-100"
: "text-surface-500 hover:text-surface-300"
)}
disabled={mode === "diff" && !tab.diff}
title={mode === "diff" && !tab.diff ? "No diff available" : label}
>
<Icon className="w-3 h-3" />
{label}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { useState, useRef } from "react";
import { ZoomIn, ZoomOut, Maximize2, Image as ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface ImageViewerProps {
src: string;
path: string;
}
export function ImageViewer({ src, path }: ImageViewerProps) {
const [zoom, setZoom] = useState(1);
const [fitMode, setFitMode] = useState<"fit" | "actual">("fit");
const [error, setError] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const handleZoomIn = () => setZoom((z) => Math.min(z * 1.25, 8));
const handleZoomOut = () => setZoom((z) => Math.max(z / 1.25, 0.1));
const handleFitToggle = () => {
setFitMode((m) => (m === "fit" ? "actual" : "fit"));
setZoom(1);
};
const isSvg = path.endsWith(".svg");
const hasTransparency = path.endsWith(".png") || path.endsWith(".gif") ||
path.endsWith(".webp") || path.endsWith(".svg");
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 text-surface-500">
<ImageIcon className="w-10 h-10" />
<p className="text-sm">Failed to load image</p>
<p className="text-xs text-surface-600">{path}</p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center gap-1 px-2 py-1 border-b border-surface-800 bg-surface-900/50">
<button
onClick={handleZoomOut}
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
title="Zoom out"
>
<ZoomOut className="w-3.5 h-3.5" />
</button>
<span className="text-xs text-surface-400 w-12 text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
title="Zoom in"
>
<ZoomIn className="w-3.5 h-3.5" />
</button>
<div className="w-px h-4 bg-surface-800 mx-1" />
<button
onClick={handleFitToggle}
className={cn(
"flex items-center gap-1 px-2 py-0.5 rounded text-xs transition-colors",
fitMode === "fit"
? "text-brand-400 bg-brand-900/30"
: "text-surface-500 hover:text-surface-200 hover:bg-surface-800"
)}
title={fitMode === "fit" ? "Switch to actual size" : "Switch to fit width"}
>
<Maximize2 className="w-3 h-3" />
{fitMode === "fit" ? "Fit" : "Actual"}
</button>
</div>
{/* Image container */}
<div
ref={containerRef}
className="flex-1 overflow-auto flex items-center justify-center p-4"
style={{
backgroundImage: hasTransparency
? "linear-gradient(45deg, #3f3f46 25%, transparent 25%), linear-gradient(-45deg, #3f3f46 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #3f3f46 75%), linear-gradient(-45deg, transparent 75%, #3f3f46 75%)"
: undefined,
backgroundSize: hasTransparency ? "16px 16px" : undefined,
backgroundPosition: hasTransparency ? "0 0, 0 8px, 8px -8px, -8px 0px" : undefined,
backgroundColor: hasTransparency ? "#27272a" : "#1a1a1e",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={path.split("/").pop()}
onError={() => setError(true)}
style={{
transform: `scale(${zoom})`,
transformOrigin: "center center",
maxWidth: fitMode === "fit" ? "100%" : "none",
maxHeight: fitMode === "fit" ? "100%" : "none",
imageRendering: zoom > 2 ? "pixelated" : "auto",
}}
className="transition-transform duration-150 shadow-lg"
draggable={false}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,250 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { X, ChevronUp, ChevronDown, Regex, CaseSensitive } from "lucide-react";
import { cn } from "@/lib/utils";
interface SearchBarProps {
content: string;
containerRef: React.RefObject<HTMLDivElement>;
onClose: () => void;
}
export function SearchBar({ content, containerRef, onClose }: SearchBarProps) {
const [query, setQuery] = useState("");
const [isRegex, setIsRegex] = useState(false);
const [caseSensitive, setCaseSensitive] = useState(false);
const [currentMatch, setCurrentMatch] = useState(0);
const [totalMatches, setTotalMatches] = useState(0);
const [hasError, setHasError] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
// Compute matches
useEffect(() => {
if (!query) {
setTotalMatches(0);
setCurrentMatch(0);
clearHighlights();
return;
}
try {
const flags = caseSensitive ? "g" : "gi";
const pattern = isRegex ? new RegExp(query, flags) : new RegExp(escapeRegex(query), flags);
const matches = Array.from(content.matchAll(pattern));
setTotalMatches(matches.length);
setCurrentMatch(matches.length > 0 ? 1 : 0);
setHasError(false);
} catch {
setHasError(true);
setTotalMatches(0);
setCurrentMatch(0);
}
}, [query, isRegex, caseSensitive, content]);
// Apply DOM highlights
useEffect(() => {
if (!containerRef.current) return;
clearHighlights();
if (!query || hasError || totalMatches === 0) return;
try {
const flags = caseSensitive ? "g" : "gi";
const pattern = isRegex ? new RegExp(query, flags) : new RegExp(escapeRegex(query), flags);
const walker = document.createTreeWalker(
containerRef.current,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
// Skip nodes inside already-marked elements
if ((node.parentElement as HTMLElement)?.tagName === "MARK") {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
}
);
const textNodes: Text[] = [];
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
textNodes.push(node);
}
let matchIdx = 0;
// Process in reverse order to avoid position shifting
const replacements: { node: Text; ranges: { start: number; end: number; idx: number }[] }[] = [];
for (const textNode of textNodes) {
const text = textNode.textContent ?? "";
pattern.lastIndex = 0;
const nodeRanges: { start: number; end: number; idx: number }[] = [];
let m: RegExpExecArray | null;
while ((m = pattern.exec(text)) !== null) {
nodeRanges.push({ start: m.index, end: m.index + m[0].length, idx: matchIdx++ });
if (m[0].length === 0) break; // prevent infinite loop on zero-width matches
}
if (nodeRanges.length > 0) {
replacements.push({ node: textNode, ranges: nodeRanges });
}
}
// Apply replacements in document order but process ranges in reverse
for (const { node: textNode, ranges } of replacements) {
const text = textNode.textContent ?? "";
const fragment = document.createDocumentFragment();
let lastEnd = 0;
for (const { start, end, idx } of ranges) {
if (start > lastEnd) {
fragment.appendChild(document.createTextNode(text.slice(lastEnd, start)));
}
const mark = document.createElement("mark");
mark.className = cn(
"search-highlight",
idx === currentMatch - 1 ? "search-highlight-current" : ""
);
mark.textContent = text.slice(start, end);
fragment.appendChild(mark);
lastEnd = end;
}
if (lastEnd < text.length) {
fragment.appendChild(document.createTextNode(text.slice(lastEnd)));
}
textNode.parentNode?.replaceChild(fragment, textNode);
}
// Scroll current match into view
const currentEl = containerRef.current?.querySelector(".search-highlight-current");
currentEl?.scrollIntoView({ block: "center", behavior: "smooth" });
} catch {
// Ignore DOM errors
}
return () => {
if (containerRef.current) clearHighlights();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, isRegex, caseSensitive, currentMatch, totalMatches]);
function clearHighlights() {
if (!containerRef.current) return;
const marks = containerRef.current.querySelectorAll("mark.search-highlight");
marks.forEach((mark) => {
mark.replaceWith(mark.textContent ?? "");
});
// Normalize text nodes
containerRef.current.normalize();
}
const goNext = () => {
setCurrentMatch((c) => (c >= totalMatches ? 1 : c + 1));
};
const goPrev = () => {
setCurrentMatch((c) => (c <= 1 ? totalMatches : c - 1));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.shiftKey ? goPrev() : goNext();
}
if (e.key === "Escape") {
clearHighlights();
onClose();
}
};
return (
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-surface-800 bg-surface-900/90 backdrop-blur-sm">
<div
className={cn(
"flex items-center gap-1 flex-1 bg-surface-800 rounded px-2 py-1",
hasError && "ring-1 ring-red-500/50"
)}
>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search..."
className="flex-1 bg-transparent text-xs text-surface-100 placeholder-surface-500 outline-none min-w-0"
/>
{/* Match count */}
{query && (
<span className={cn(
"text-xs flex-shrink-0",
hasError ? "text-red-400" : totalMatches === 0 ? "text-red-400" : "text-surface-400"
)}>
{hasError ? "Invalid regex" : totalMatches === 0 ? "No results" : `${currentMatch}/${totalMatches}`}
</span>
)}
{/* Toggles */}
<button
onClick={() => setCaseSensitive((v) => !v)}
className={cn(
"p-0.5 rounded transition-colors flex-shrink-0",
caseSensitive
? "text-brand-400 bg-brand-900/40"
: "text-surface-500 hover:text-surface-300"
)}
title="Case sensitive"
>
<CaseSensitive className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setIsRegex((v) => !v)}
className={cn(
"p-0.5 rounded transition-colors flex-shrink-0",
isRegex
? "text-brand-400 bg-brand-900/40"
: "text-surface-500 hover:text-surface-300"
)}
title="Use regular expression"
>
<Regex className="w-3.5 h-3.5" />
</button>
</div>
{/* Navigation */}
<button
onClick={goPrev}
disabled={totalMatches === 0}
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 disabled:opacity-30 transition-colors"
title="Previous match (Shift+Enter)"
>
<ChevronUp className="w-3.5 h-3.5" />
</button>
<button
onClick={goNext}
disabled={totalMatches === 0}
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 disabled:opacity-30 transition-colors"
title="Next match (Enter)"
>
<ChevronDown className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { clearHighlights(); onClose(); }}
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
title="Close (Escape)"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,64 @@
"use client";
import { Sun, Moon, Monitor } from "lucide-react";
import { useTheme } from "./ThemeProvider";
import { useChatStore } from "@/lib/store";
import { MODELS } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { NotificationCenter } from "@/components/notifications/NotificationCenter";
export function Header() {
const { theme, setTheme } = useTheme();
const { settings, updateSettings } = useChatStore();
const themeIcons = {
light: Sun,
dark: Moon,
system: Monitor,
} as const;
const ThemeIcon = themeIcons[theme];
const nextTheme = theme === "dark" ? "light" : theme === "light" ? "system" : "dark";
return (
<header className="flex items-center justify-between px-4 py-2.5 border-b border-surface-800 bg-surface-900/50 backdrop-blur-sm">
<div className="flex items-center gap-3">
<h1 className="text-sm font-medium text-surface-100">Chat</h1>
</div>
<div className="flex items-center gap-2">
{/* Model selector */}
<label htmlFor="model-select" className="sr-only">
Model
</label>
<select
id="model-select"
value={settings.model}
onChange={(e) => updateSettings({ model: e.target.value })}
className={cn(
"text-xs bg-surface-800 border border-surface-700 rounded-md px-2 py-1",
"text-surface-300 focus:outline-none focus:ring-1 focus:ring-brand-500"
)}
>
{MODELS.map((m) => (
<option key={m.id} value={m.id}>
{m.label}
</option>
))}
</select>
{/* Notification center */}
<NotificationCenter />
{/* Theme toggle */}
<button
onClick={() => setTheme(nextTheme)}
aria-label={`Switch to ${nextTheme} theme`}
className="p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
>
<ThemeIcon className="w-4 h-4" aria-hidden="true" />
</button>
</div>
</header>
);
}

Some files were not shown because too many files have changed in this diff Show More