Internals
cc-review is one Go binary. Every user-facing command in the CLI reference talks to a background daemon over a unix socket, and that same daemon serves the web UI over HTTP on 127.0.0.1. This page explains how those pieces fit together.
Daemon lifecycle
There is no install step for the daemon and no service manager. Every user-facing CLI command calls daemon.EnsureCurrent before doing work. If no daemon at the current binary version answers on the socket, the CLI spawns a detached cc-review daemon and waits for it to come up. The daemon subcommand is a hidden cobra command in internal/cli/hidden.go.
Cold starts are serialized by an exclusive flock on ~/.cc-review/v1/locks/start.lock, so concurrent commands racing to boot the daemon produce exactly one process. The SessionStart hook uses EnsureCurrentIfRunning instead, which upgrades a running daemon without ever booting one, because a hook must not be the thing that starts daemons. The edit-guard hook skips the handshake entirely: it talks to whatever daemon answers the socket and fails open when none does.
Version skew resolves newest-wins on first contact. When a CLI finds a daemon built from a strictly older binary, the freshly spawned daemon’s listen() evicts the holder: it asks the old daemon to shut down over the socket, escalates to SIGKILL if it wedges, and waits for the old process to exit before binding. A same-or-newer holder is never evicted — the spawned daemon exits with an error instead, and the spawning client accepts the running daemon. The tie refusal is what prevents two daemons from evicting each other in a loop, and the newer-holder refusal is what stops sessions pinned to older plugin builds from tearing down the shared daemon every turn. A dev build counts as newest: a dev daemon is never evicted, and a dev binary always takes over a release daemon.
The HTTP plane binds a 127.0.0.1 port and publishes it to ~/.cc-review/v1/http.json so the CLI and stream consumers can find it. The file is left in place on shutdown, and a booting daemon tries that previous port first before falling back to an ephemeral one, so printed review URLs survive a daemon swap. With cc-review daemon --dev the port is pinned to 8787, which is where the Vite dev proxy expects to find the API during frontend work.
Daemons spawned by the CLI append their stdout and stderr to ~/.cc-review/v1/daemon.log — boot lines, eviction sequences, and panics all land there across daemon generations. A manual cc-review daemon run keeps its output on the terminal.
Two planes
The daemon exposes two surfaces.
The control plane is a unix socket at ~/.cc-review/v1/daemon.sock (mode 0600) speaking exact protocol v1. This is what CLI commands call. The ops dispatched in internal/daemon are health, shutdown, start, resolve, reply, feedback, status, session-record, guard-edit, file-states, update-ai-request, submit-organization, and review-files. A mismatched protocol is rejected before dispatch.
The HTTP plane (internal/httpapi) binds 127.0.0.1 only, and that bind is the entire access-control story. It serves the embedded SPA at /, a JSON REST surface, and one SSE stream. These routes are registered in internal/httpapi/server.go.
GET /api/session/{reviewId}
GET /api/session/{reviewId}/versions
POST /api/comments
PUT /api/comments/{id}
POST /api/replies/{commentId}
POST /api/file-states
POST /api/ai-requests
POST /api/ai-requests/{id}/undo
POST /api/submit
GET /events
The event log
All realtime behavior rides on one append-only events table, keyed (review_id, seq). The daemon’s AppendEvent is the single chokepoint: it persists the row, then publishes a wakeup on an in-memory bus so parked SSE handlers re-read the log.
Delivery is at-least-once. GET /events?session=<ref> streams a review’s log; consumers resume from their last sequence number via Last-Event-ID, or via the ?last_event_id= query fallback since native EventSource cannot set headers on the initial request. The Claude-side consumers, watch and the MCP channel server, persist their cursor on disk per consumer, so a restart resumes without re-delivering.
Each event carries an origin of user, claude, or system. The browser subscribes with no filter and sees everything; Claude-side consumers pass exclude_origin=claude so they never receive an echo of their own replies. Duplicate suppression on the write side uses an optional dedup_key with a unique partial index, so a redelivered reply inserts once and re-emits nothing.
Named consumers also register presence: their attach and detach transitions drive channel.changed events, which is how the UI knows whether a live Claude session is wired to the review. channel.changed is delivered to the browser only — named consumer streams (channel, watch) filter it out, since a consumer learning about its own attachment is noise.
Presence alone never proves delivery: Claude Code silently drops channel notifications when channels are unavailable, so channel: active requires the model to have acknowledged a delivered tag via channel-ack. A start on an attached-but-unproven window solicits that proof by injecting a one-shot channel.probe frame into exactly that window’s channel stream. The probe bypasses the event log and carries no SSE id — it cannot replay on reconnect, and neither the browser nor watch ever sees it. It lands while the model is mid-turn running the start skill, so no idle session is ever woken.
Storage
State lives in a single SQLite database via modernc.org/sqlite (pure Go, no cgo). cc-interact/store owns the single-writer connection, WAL journaling, busy timeout, core tables, and exact schema fingerprint; internal/store contributes cc-review’s declarative schema and domain CRUD.
The database carries an exact v1 schema marker and fingerprint. There are no migrations: a future schema epoch uses a fresh namespace, while the derived ~/.cc-review/v1 tree can be discarded and rebuilt. Large patches stay out of the database; a version row stores only the patch path and a files summary.
State directory layout
internal/paths owns the layout under ~/.cc-review/v1. The directory is always under the home directory; CLAUDE_CONFIG_DIR does not move it.
~/.cc-review/v1/
├── daemon.sock # control-plane unix socket
├── daemon.log # spawned daemons append stdout/stderr here
├── http.json # HTTP port handshake, kept across restarts for port reuse
├── channels-setup.json # marker: the one-time channels offer was made
├── locks/
│ └── start.lock # flock serializing lazy daemon starts
├── cc-interact-v1/
│ ├── state.db # exact core + review + turn schema
│ └── subjects/<review-id>/
│ ├── watch.cursor
│ └── channel.cursor
└── subjects/<review-id>/
├── snap_N.patch # unified patch for version N
└── feedback_N.json # frozen feedback for version N (written on Submit)
Working-tree snapshots
internal/vcs turns a working copy’s pending changes into a git-format patch. Detection walks upward from the cwd without spawning a subprocess. A .jj directory means jj; a .git entry means git, and the check accepts both a directory and a file since git worktrees use a file. In a colocated repo jj wins. Git diffs against HEAD, or against the empty tree in a fresh repo; jj diffs against the working-copy parent.
Each start captures a new snapshot and inserts a new version row, writing the patch to a temp file first and renaming it into place so a write failure can never leave a committed-but-unreadable version. Per-file fingerprints let reviewed marks survive across versions: a file stays marked reviewed exactly while its diff content is unchanged.
The embedded SPA
The frontend is a Vite + React app in web/, built into internal/web/dist and compiled into the binary with go:embed. Because the embed happens at compile time, the web build must run before the Go build:
cd web && bunx vite build
cd .. && go build ./cmd/cc-reviewA committed placeholder index.html keeps a clean tree compiling; a real build replaces it with hashed assets. The HTTP plane registers the SPA handler last and least-specific, so /api and /events always win routing.