- TypeScript 93.3%
- Rust 3.9%
- Go 0.9%
- CSS 0.8%
- JavaScript 0.7%
- Other 0.4%
The bridge replaces the provider's echoed model with our internal routing handle (model.value) before it reaches claude, so logs/proxy_requests only ever show the handle (e.g. neuralwatt-2/glm-5.2-flex) — never what the provider ACTUALLY served for the apiModelId we sent (glm-5.2). Log the raw upstream `model` once per turn (from the already-buffered stream head; non-streaming reads data.model) so we can confirm whether a provider serves a request from a different tier/pool than asked. |
||
|---|---|---|
| .agents/skills/elysiajs | ||
| .forgejo/workflows | ||
| docs | ||
| packages | ||
| .gitattributes | ||
| .gitignore | ||
| bun.lock | ||
| CLAUDE.md | ||
| package.json | ||
| README.md | ||
| skills-lock.json | ||
cc-web
A web UI for Claude Code. The backend can drive claude two ways, chosen by
config.adapter (see turn-engine.ts):
- interactive (default) — spawn the real interactive TUI inside a pseudo-terminal and reconstruct the whole turn (text, thinking, tool calls, results) from Claude Code's hook stream, re-deriving the authoritative order from the transcript (hold-then-correct, defeating TUI ordering races).
- cli — spawn
claude -p --output-format stream-jsonand translate its already-ordered event stream (with real token deltas) into the same turn model, streaming each event straight to the client — no server-side ordering buffer, no transcript reconciliation.
Both reuse the identical hooks / permissions / MCP-apps machinery (a PreToolUse
hook gates a tool the same way under claude -p as under the TUI). Backend = Bun +
Elysia, frontend = React + Vite.
This is v1. It demonstrates the hard parts:
- streaming a live TUI turn to the browser over WebSocket,
- native permission modes (
default/acceptEdits/plan/auto/bypass) with per-tool Approve/Deny cards when the agent wants to run something, - a model selector (Haiku / Sonnet / Opus),
- session continuity across turns (resume + fork),
- durable turns — the backend owns each turn: it reconstructs and saves the transcript as events arrive, so a turn keeps running and is persisted even if you close the tab, and a reconnecting client is re-synced to the live turn (and told which one is active). In-flight turns are also flushed on shutdown.
Why drive the TUI?
The headless -p path can't reach a lot of what the real client does — native
permission modes, plan mode, the trust model, etc. So we run the actual
interactive claude and treat its hooks as the API. The trade-off is that
the TUI renders to a terminal, not a stream, so we reassemble the event stream
from several signals. That reassembly is most of the "weird TUI shit" below.
Architecture
browser ──ws──> backend ──spawn in PTY──> claude (interactive TUI)
▲ │ │
│ │ --settings (hook ext) │ writes
│ events ▼ ▼ transcript .jsonl
│ (text, /internal/hook <──http── cc-extension/hook.ts │
│ tool, │ ▲ (fires on EVERY hook event; │
│ result, │ │ PreToolUse BLOCKS for the │
│ thinking, │ └── allow/deny ── permission verdict) │
│ prompts) │ │
└───────────────┤◀── thinking ── TranscriptTail tails ───────────┘
│
permission_request ──ws──> browser ──ws──> decision
Launch (claude-tui.ts + pty.ts). We resolve the claude binary and spawn
it in a real PTY (ConPTY on Windows, forkpty elsewhere) so the TUI starts in
interactive mode instead of detecting a pipe and degrading. The prompt is passed
positionally so it auto-submits — no scripted keystrokes. Continuing a chat uses
--resume <id> --fork-session, so every turn writes a fresh transcript and the
original branch stays frozen.
The hook extension (cc-extension/). A single universal hook is registered
for every lifecycle event via a generated --settings file. It POSTs each
event to the backend's /internal/hook (authenticated by a per-process secret +
turn id). For PreToolUse it blocks on the backend's reply and fails closed
(deny) on any error, so nothing runs unapproved.
Rebuilding the turn (claude-tui.ts):
| stream element | source |
|---|---|
| assistant text | MessageDisplay hook (delta) |
| tool calls | PreToolUse hook |
| tool results | PostToolUse / PostToolUseFailure |
| model thinking | the transcript .jsonl, tailed by TranscriptTail |
| live "Thinking…" start | the PTY's animated working line (instant) |
| turn end + final text | Stop |
The subtle bits
- Thinking comes from the transcript. No hook carries the model's reasoning,
so
TranscriptTailis the one place we read the session.jsonl. The "Thinking…" indicator opens immediately off the PTY working line; the reasoning text streams in from the transcript. - Tool-card ordering grace. The TUI fires a tool's
PreToolUsea few tens of ms before theMessageDisplayof the preamble that precedes it ("Let me check…", then the tool). We hold each tool card for a short grace (TOOL_ORDER_GRACE_MS) so the preamble lands first, force-flushing held cards before any result or turn close. - Forked-history skip. A forked session copies prior turns into the new transcript after we've pointed the tailer at it. We skip the copy by timestamp — entries stamped before the turn began are a fork artifact.
- Workspace trust (
trust.ts). The TUI's one-time "do you trust this folder?" gate isn't bypassed by--permission-mode, so we pre-writehasTrustDialogAcceptedin~/.claude.jsonfor any directory added as a project (with a keystroke fallback in the controller).
Permission modes
The dropdown next to the composer sets the turn's mode, sent on the message and
mapped to a native --permission-mode (permission-modes.ts):
| mode | behaviour |
|---|---|
default |
ask before edits and commands |
acceptEdits |
auto-accept edits inside the workspace; ask for the rest |
plan |
read-only; Claude researches and proposes a plan |
auto |
Claude's native classifier decides |
bypass |
run everything, no prompts |
The PreToolUse hook is still the real gate: decidePermission decides, per
tool, whether to allow, deny, relay to the human (the Approve/Deny card), or
defer to the native mode. Read-only tools are always auto-approved.
Shift+Tab cycles modes (skipping bypass); bypass is hidden until you
Shift-click the dropdown and is gated behind a confirmation dialog.
Run
bun install
bun run dev # backend (:3001) + frontend (:5173) together
Open http://localhost:5173, create an account, and add a project (any directory on disk). Claude's file operations happen in that project directory.
Individually:
bun run dev:backend
bun run dev:frontend
Deployment
The two halves deploy differently: the web UI is a static bundle, the API is a long-running process.
Web UI — automated
.forgejo/workflows/deploy.yml runs on every push to main (Forgejo
local-runner): it builds the frontend with VITE_API_URL=https://api.code.nullp.tr
baked in and rsyncs packages/frontend/dist/ to /var/www/code. Caddy serves
it at https://code.nullp.tr with an SPA fallback:
code.nullp.tr {
root * /var/www/code
encode zstd gzip
try_files {path} /index.html # client-side routing
file_server
}
api.code.nullp.tr {
reverse_proxy localhost:3001 # WS upgrade + X-Forwarded-Proto handled automatically
}
The frontend's API origin is build-time (VITE_API_URL); the WebSocket URL is
derived from it (https→wss). The backend's allowed origin is runtime
(FRONTEND_ORIGIN). Because code.nullp.tr and api.code.nullp.tr share a
registrable domain they are same-site, so the Lax, Secure session cookie
flows on cross-subdomain requests and the Sec-Fetch-Site guard doesn't trip.
Caddy's X-Forwarded-Proto: https is what flips the cookie to Secure.
API — run on the host
The backend spawns the real claude CLI in a PTY, so it needs the CLI on
PATH, your ~/.claude auth, and access to the project directories — run it
on the host, not in a container. A systemd unit, run as your user:
# /etc/systemd/system/cc-web-api.service
[Service]
WorkingDirectory=/path/to/cc-web/packages/backend
ExecStart=/home/youruser/.bun/bin/bun run src/index.ts
Environment=PORT=3001
Environment=FRONTEND_ORIGIN=https://code.nullp.tr
User=youruser
Restart=on-failure
[Install]
WantedBy=multi-user.target
Before exposing it publicly, harden packages/backend/config.json (gitignored,
not deployed): set signupsEnabled: false after creating your account, and set
workspaceRoot to confine projects/file-browsing to a dedicated directory.
Status / next steps
- v1 keeps a single linear conversation per chat; the backend already forks on
resume (
--fork-session), the building block for a future conversation tree (edit a past message → branch without losing the original). - The headless path is now a first-class engine (
claude-cli.ts, selected viaconfig.adapter: "cli") that reuses the hook-based permission gate rather than the old--permission-prompt-toolMCP server. The original deadclaude.ts/permission-mcp.tsreference implementation has been removed.