- TypeScript 93.4%
- Rust 3.9%
- Go 0.9%
- CSS 0.8%
- JavaScript 0.7%
- Other 0.3%
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> |
||
|---|---|---|
| .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.