Claude Code for the web
  • TypeScript 93.3%
  • Rust 3.9%
  • Go 0.9%
  • CSS 0.8%
  • JavaScript 0.7%
  • Other 0.4%
Find a file
not-nullptr 36e6ef9b17
Some checks failed
Deploy / build-and-deploy (push) Failing after 22s
Desktop Release / build-windows (push) Successful in 2m46s
diag(proxy): log the upstream-served model on the openai-bridge path
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.
2026-07-07 17:06:09 +01:00
.agents/skills/elysiajs Initial commit: Claude Code web UI 2026-06-05 22:07:56 +01:00
.forgejo/workflows feat(daemon): cross-compile + ship a macOS Intel build 2026-07-02 13:22:15 +00:00
docs docs: mark the sandbox feature re-landed (66a40a1) 2026-07-04 20:12:42 +01:00
packages diag(proxy): log the upstream-served model on the openai-bridge path 2026-07-07 17:06:09 +01:00
.gitattributes Initial commit: Claude Code web UI 2026-06-05 22:07:56 +01:00
.gitignore docs(reference): persist the Claude Code VSCode extension RE in-repo 2026-07-03 20:24:10 +00:00
bun.lock feat(vr): add SteamVR companion app for in-VR permission/plan/question prompts 2026-07-03 20:53:39 +00:00
CLAUDE.md feat(sandbox): re-land hot-patch hook + non-lossy recreate + in-place claude updates 2026-07-04 20:11:25 +01:00
package.json build(types): use @types/bun and pin @types/node to one major 2026-06-09 14:21:56 +00:00
README.md feat(adapter): add a "cli" turn engine (claude -p stream-json) beside the TUI 2026-06-16 03:36:58 +00:00
skills-lock.json Initial commit: Claude Code web UI 2026-06-05 22:07:56 +01:00

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-json and 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 TranscriptTail is 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 PreToolUse a few tens of ms before the MessageDisplay of 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-write hasTrustDialogAccepted in ~/.claude.json for 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 (httpswss). 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 via config.adapter: "cli") that reuses the hook-based permission gate rather than the old --permission-prompt-tool MCP server. The original dead claude.ts / permission-mcp.ts reference implementation has been removed.