Claude Code for the web
  • TypeScript 93.4%
  • Rust 3.9%
  • Go 0.9%
  • CSS 0.8%
  • JavaScript 0.7%
  • Other 0.3%
Find a file
not-nullptr b724715dfc
Some checks failed
Deploy / build-and-deploy (push) Failing after 19s
Desktop Release / build-windows (push) Successful in 2m59s
feat(models): tier-agnostic "any" tier — one model for all three tiers
A single-model provider (e.g. a local llama.cpp box with only enough VRAM for
one loaded model) can now be added as ONE alt model that serves every tier,
instead of three near-duplicate rows with three routing handles.

- shared: ModelTier gains "any" (meaningful for alt models only)
- altModelTierEnv: an "any" model pins ALL THREE ANTHROPIC_DEFAULT_*_MODEL vars
  at its routing handle, so the main turn, subagents, and claude's own
  background haiku calls all land on it. Previously only the selected tier's
  var was set and the other two were pinned to the first alt model of that tier
  GLOBALLY (which could be a different provider).
- modelTierArg: an "any" model impersonates the opus alias
- resolveHaikuForGroup: returns the model itself — it was falling through to
  Anthropic haiku, quietly taking chat titling off the alt provider
- modelFamily: reports the impersonated "opus" family for spend/log labels
- groups: a group is now EITHER one "any" model OR the classic 3-per-tier trio.
  The rule moved into resolveGroupModels() (model-admin.ts) so it can read each
  model's tier, and updateGroupRow now validates BEFORE mutating (a rejected
  edit no longer leaves the group renamed with its members unassigned).
- a grouped model's tier can no longer change under its group: the admin UI
  disables the tier select, and the API refuses (mirrors the delete rule)
- admin UI: "Any tier (one model for all)" model option; the group dialog takes
  a single any-tier model, and a group card renders it as one "All tiers" entry

Tests: new test/model-group-any-tier.test.ts (group shapes, tier lock,
validate-before-mutate) + extended alt-model-tier-env and models tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-15 17:38:52 +00: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 feat(models): tier-agnostic "any" tier — one model for all three tiers 2026-09-15 17:38:52 +00: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 (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 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.