Files
web-terminal/CLAUDE.md
Yaojia Wang 1137090626 chore: adopt one-worktree-per-session workflow
Sessions that change files now work in their own git worktree and merge back to
`develop`, instead of developing directly in the main checkout. Documents the
tool's actual behaviour rather than the assumed one: `EnterWorktree` prefixes the
branch as `worktree-<name>`, branches from the last commit (so uncommitted edits
do not carry over), and `ExitWorktree({action: "remove"})` deletes the branch
with the directory — hence the merge must happen before the cleanup.

Base ref is pinned to `head` in `.claude/settings.json` because `develop` runs
well ahead of `origin/main`, and the default `fresh` would branch from the stale
release trunk.

Also ignores `.claude/worktrees/`, which lives on disk per session and is never
committed.
2026-07-29 11:13:26 +02:00

17 KiB
Raw Permalink Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Status

Built and working — v0.1 (core) + v0.2 (multi-tab) + v0.3 (Claude Code cockpit) shipped. src/ (TypeScript backend), public/ (esbuild frontend), and tests all exist; 212 tests pass. v0.3 work happens on the v0.3-cockpit branch. Key docs:

  • docs/TECH_DOC.md — design & rationale (the why). Protocol (§4), session model (§5.2), security (§7), acceptance (§8).
  • docs/ARCHITECTURE.md — function-signature-level contracts, dependency rules, the invariant checklist (§8). On conflict, ARCHITECTURE wins on how; the M*/L* anchors are cross-validated fixes.
  • docs/PROGRESS_LOG.md — the memory file (cross-session). Read it first; it's the running record of what shipped (v0.1 T1T21, then v0.2/v0.3 feature entries). Orchestrator-owned.
  • docs/PLAN.md (v0.1 task plan) and ~/.claude/plans/shimmering-wondering-island.md (v0.3 cockpit plan).
  • .claude/agents/ — project subagents: module-builder, module-reviewer (for parallel dev).

v0.3 additions (cockpit): Claude Code hooks → live status per tab (POST /hook, npm run setup-hooks); remote approve/reject (held POST /hook/permission); tmux keepalive (USE_TMUX); plus FE: themes, search, dashboard, QR, PWA, new-tab-in-cwd. The server gained a hook side-channel but the terminal stream is still a byte-shuttle.

Language decision: TypeScript (.ts), not .js — ARCHITECTURE §0 records this divergence from TECH_DOC's original .js filenames. Wherever the two docs conflict, ARCHITECTURE wins on how (it was cross-validated and corrected); TECH_DOC wins on why/scope.

Session Workflow: One Worktree per Session (MANDATORY)

Every session that changes files works in its own git worktree, and merges back to develop when the work is done. Don't develop directly on develop in the main checkout (the one exception is a change to this workflow itself — the rule can't bootstrap inside its own worktree).

  1. Start of session — before touching any file, call the EnterWorktree tool with a task-descriptive name (e.g. EnterWorktree({name: "fix-cjk-locale"})). It creates .claude/worktrees/<name>/ on branch worktree-<name> (the tool prefixes it — the name you pass is not the branch name) and moves the session's cwd into it. Do all work there.
    • Base ref is head (configured in .claude/settings.jsonworktree.baseRef), so the worktree branches from the current develop HEAD, not origin/main. This matters: develop runs ~75 commits ahead of origin/main, so the default fresh base ref would silently produce a badly stale worktree. develop is the working trunk; main is the release branch.
    • It branches from the last commit, so uncommitted edits sitting in the main checkout do not carry over. Commit or stash them first if the task needs them.
    • Read-only sessions (answering a question, inspecting a remote host) don't need a worktree — only create one when files will change.
  2. During the session — commit inside the worktree as normal (conventional-commit format, see the global git-workflow rule). Tests/tsc run against the worktree copy, so concurrent sessions never collide on the working tree; each holds a locked worktree of its own, so never git worktree remove a directory this session didn't create.
  3. End of session — merge back. Commit everything in the worktree first, then, in this order:
    # 1. ExitWorktree({action: "keep"})  → cwd returns to the main checkout, branch survives
    git merge --no-ff worktree-<name>     # 2. from the main checkout, on develop
    git worktree remove .claude/worktrees/<name> && git branch -d worktree-<name>   # 3. clean up
    
    Order matters: ExitWorktree({action: "remove"}) deletes the branch along with the directory, so calling it before the merge throws the work away. (It does refuse when commits aren't yet on develop — a safety net, not a plan.) keep is also the right call whenever the work is unfinished and the session should be resumable.
  4. Do not merge to main as part of this flow — main is promoted from develop separately.

Subagents dispatched with isolation: worktree (PLAN §4) get their own throwaway worktrees on top of this — that is a separate, nested mechanism and does not replace the session-level worktree.

Development Workflow: Plan & Progress Log (MANDATORY)

Work proceeds against a phased plan and is tracked in a progress log that acts as cross-session memory. A new Claude instance must be able to read the log and know exactly where things stand. Follow these rules:

Required reading before starting ANY task (especially sub-agents — context is isolated):

  • docs/TECH_DOC.md — design rationale, scope, protocol (§4), session model (§5.2), security (§7), acceptance (§8). The why.
  • docs/ARCHITECTURE.md — function-signature-level contracts, dependency rules, the invariant checklist (§8). The how. On conflict, ARCHITECTURE wins on how, TECH_DOC on why/scope.
  • The task's ARCH: field (in PLAN.md) points to the exact sections to read for that task; also read any M*/L* anchors named in its Steps.
  • M1M7 / L1L5 are the 17 cross-validated fixes (M=medium, L=low) embedded as same-named anchors in ARCHITECTURE.md (some echoed in TECH_DOC.md). Seeing (M2) in a task → search ARCHITECTURE.md for "M2" to get the full rationale and correct approach. These are the easiest things to get wrong — read them before writing.

The plan (docs/PLAN.md):

  • The build is split into waves W0W5 by dependency, with fine-grained tasks T1T21 designed for multi-agent parallelism. Same-wave tasks are independent and own disjoint files; cross-wave follows dependencies. See PLAN §0§1.
  • Every task has a stable ID (e.g. T6) and an Owns: file list. Never edit files outside your task's Owns: — that's how parallel agents avoid clobbering each other. src/types.ts (T2) is the frozen shared-contract source; if a new type is needed, change it there (a coordination point), don't redeclare locally.
  • The plan is the source of intent; do not silently deviate. If reality forces a change, update PLAN.md AND record the deviation in the log.

The log (docs/PROGRESS_LOG.md) — cross-session memory. WHO writes it depends on mode (G1):

  • PROGRESS_LOG.md is a shared file — it is not in any task's Owns:. To avoid concurrent-write clobbering, only one writer touches it: the orchestrator (the main session that dispatches tasks).
  • If you are a dispatched subagent doing one task: do NOT edit PROGRESS_LOG.md. Instead, end your final message with a ready-to-paste log entry (the "条目模板" block from the log) describing status, files/functions touched, how it was verified (test/command + result), decisions/deviations, blockers, next step. That entry IS your deliverable back to the orchestrator.
  • If you are the orchestrator (or working solo): read the log at the start of every session; after each subagent returns (or after each solo sub-task), append its entry immediately and update the "当前焦点 / Current Focus" block — not in a batch at the end. An untracked completed task is a memory loss.
  • Be factual — log what actually happened (tests failing, steps skipped), never aspirational. Never pre-fill future tasks as done.

Ordering — solo: consult PLAN.md → do the task (TDD per ARCHITECTURE §6/§7, stay within Owns:) → verify → append to PROGRESS_LOG.md → next task. Commit boundaries map to tasks where practical.

Multi-agent orchestration (when running tasks in parallel)

This repo is structured for orchestrator-worker parallelism (the official Claude Code pattern). The main session is the orchestrator; it dispatches PLAN tasks to subagents, waits, then synthesizes. Rules:

  • Dispatch by wave: only same-wave, file-disjoint tasks run concurrently. Keep the parallel batch to ~35 agents (diminishing returns / token cost beyond that). PLAN §3 is the dispatch schedule; PLAN §4 gives each task's model and isolation.
  • Use the project agents in .claude/agents/: module-builder (TDD-implements one task) and module-reviewer (read-only review). Don't hand parallel work to a generic agent — these encode tool limits, the required-reading rule, the Owns: boundary, and the log-return contract.
  • Subagents cannot talk to each other or ask the user mid-task (architectural limit). So: tasks must be self-contained; if a subagent hits an ambiguity not resolved by the docs, it must stop and return a [!] BLOCKED entry — never guess. The orchestrator resolves it (asks you if needed) and re-dispatches.
  • File isolation: file-ownership (Owns:) prevents logical conflicts. For genuinely concurrent edits, also run builders with isolation: worktree (each gets a throwaway git worktree) so parallel tsc/vitest/edits never collide on the working tree. PLAN §4 marks which tasks want it.
  • Verification tasks (W5) are report-only by default: a reviewer/acceptance agent finds issues and returns them; fixes are routed back to the owning module's builder (or done by the orchestrator), so no agent edits files outside its lane.

What This Is

A browser-based terminal that exposes the host machine's local shell over WebSocket, so any device on the LAN can open http://<host-ip>:3000 and get an interactive shell. The primary use case is vibe coding — sending Claude Code a task, walking away, and reconnecting from any device (phone/tablet/another computer) to check on it. This drives two non-obvious requirements: sessions must survive disconnects and the mobile UI must be usable.

Explicitly out of scope (v0.1): auth/login, multi-user isolation, SSH jump hosts, containers, and any public-internet exposure.

Planned Commands

npm install   # triggers node-pty native compilation (needs Xcode CLT on macOS)
npm start     # listens on 0.0.0.0:3000
npm test      # unit tests (vitest, all modules)

Config is via env vars only (no hardcoding): PORT, SHELL_PATH, BIND_HOST, IDLE_TTL, SCROLLBACK_BYTES, MAX_PAYLOAD_BYTES, USE_TMUX (1/0/auto), ALLOWED_ORIGINS. Note allowedOrigins is derived from the host's network-interface IPs (not from BIND_HOST0.0.0.0 is never a valid Origin); see ARCHITECTURE §3.1.

WEBTERM_TOKEN (w5-access-token, optional) — a shared access token that gates the WS handshake (alongside, not replacing, the Origin check) and every remote HTTP route. Unset ⇒ auth disabled, so LAN zero-config is preserved exactly as before; only when set does the gate activate. When set it must be 16512 URL/cookie-safe chars ([A-Za-z0-9._~+/=-]) or the server refuses to start. Deliver it once via GET /?token=<t> (or POST /auth), which sets an HttpOnly; SameSite=Strict; Secure-when-https cookie the browser auto-sends thereafter; loopback hook ingest (/hook*) is exempt so the smart-features side-channel keeps working. Honest tradeoff: it is a bar-raiser, not a TLS/Tailscale substitute — on bare ws:// the token travels in cleartext and is replayable by a LAN sniffer; it only meaningfully hardens the relay/tunnel (TLS-terminated) path. Never port-forward the raw port to the internet. See src/http/auth.ts and docs/plans/w5-access-token.md.

Architecture (the parts that span files)

The server is a byte-shuttle, not a terminal. It does not parse ANSI/terminal semantics — xterm.js (browser) interprets escape sequences and renders; node-pty (server) provides the pseudo-terminal so the shell believes it has a real TTY. This separation is the central simplification — keep it. Don't add terminal-semantic parsing on the server.

Data flow: keypress → xterm onData → WS → pty.write() → shell stdin, and shell stdout → pty onData → WS → xterm.write() → screen.

Session/connection decoupling (the most important design point)

PTY lifecycle ≠ WebSocket lifecycle. A WS disconnect must NOT kill the PTY — the Claude Code task running inside has to keep going. Sessions are keyed by sessionId (client stores it in localStorage):

  • Home = a session chooser (v0.5), not an auto-tab. Opening the app does NOT auto-create a blank session or auto-restore/discover tabs. It lands on the launcher (public/launcher.ts): a grid of the host's running sessions as live preview thumbnails — the user picks one to open (full scrollback replay) or + New session. Closing the last tab returns to the chooser.
  • attach(null) → spawn a new PTY + shell; PTY output goes into a ~2MB ring buffer and (if a WS is attached) forwards live.
  • attach(sessionId) → look up the session, replay the ring buffer, then resume the live stream. This is what makes "refresh the page and the Claude session is still there" work.
  • WS close → detach one client (PTY keeps running), not kill; the idle clock starts only when the last client leaves.
  • Multi-device sharing (v0.4): a session may have many concurrent WS clients — a new attach JOINS (mirror), it does not kick. Output/exit/status broadcast to all; any client can type (shared control). PTY sizing = latest-writer-wins: the device that most recently fit/focused drives the size, so whichever device you're actively using is full-screen (a shared PTY can only be one size; min-sizing letterboxed the bigger screen). Attach/detach never resize — the active device keeps its size; the frontend re-sends dims on pane-show and window-focus so switching devices reclaims full-screen. (The earlier blur size-vote message was removed in the min→latest-writer pivot.) This relaxes the original "one WS per session" invariant (#5). GET /live-sessions lists running sessions so the v0.5 home launcher (session chooser) shows them as live preview thumbnails to pick from; ?join=<id> and the 🔗 share-QR open a specific shared session; the 🗂 manage page (/manage.html) is a full-page grid of live preview thumbnails — each card renders the session's current screen via a read-only xterm (fed by GET /live-sessions/:id/previewRingBuffer.tail(), no attach) so you can see what each session is doing, with open/kill (DELETE /live-sessions[/:id]).
  • Orphan reclaim: detached longer than IDLE_TTL (default 24h) with no new output since detach → reclaim. (node-pty can't reliably detect a foreground child, so liveness is approximated via last-output time — see ARCHITECTURE §3.5 / M3.) On server exit, pty.kill() all sessions (no cross-restart persistence in v0.1; tmux backend is a v0.2 idea).

Represent session state as immutable snapshots (id/start-time fixed at creation); hold mutable runtime handles (pty/ws) separately.

WebSocket protocol (protocol.ts)

Single WS connection, JSON text frames. attach must be the first message. Client→server types: attach / input / resize. Server→client types: attached / output / exit. See TECH_DOC §4 for exact shapes.

resize must be its own message type (it triggers ioctl(TIOCSWINSZ) → SIGWINCH so full-screen TUIs like vim/top redraw correctly). Validate at the boundary: type must be whitelisted, resize cols/rows must be integers 11000, input.data must be a string passed through verbatim (it's raw keyboard bytes — do not filter content).

Security (non-negotiable)

This app hands a full shell to anyone who can reach the port. Origin-header validation on the WS handshake is the one defense that cannot be skipped — it blocks Cross-Site WebSocket Hijacking (a malicious page in your browser trying to connect to ws://<lan-ip>:3000). Reject foreign origins (401). Never port-forward / tunnel this to the public internet. Tailscale is the recommended deployment over bare LAN.

Frontend (public/)

xterm.js + FitAddon. WS URL is same-origin, with the scheme following the page protocol (wss: on HTTPS, else ws:) so no IP config is needed and Tailscale/TLS (HTTPS) deploys avoid mixed-content blocking (M6). Auto-reconnect with exponential backoff (1s/2s/4s… cap 30s), carrying the localStorage sessionId. A mobile touch key-bar (hidden >768px) sends Esc \x1b, Shift+Tab \x1b[Z, arrows, Enter \r, Ctrl+C \x03, Tab \t directly via ws.send (bypassing xterm to avoid popping the soft keyboard) — these are Claude Code's high-frequency keys that phone keyboards can't produce.

Gotchas (from the spec)

  • Enter sends \r (0x0D), not \n — easy to get wrong when synthesizing input.
  • fit() must run after the container has real dimensions; calling it while display:none yields NaN.
  • node-pty needs npm rebuild after a major Node version bump.
  • Don't double-handle IME — xterm.js already manages composition events; don't add your own keydown listener for CJK input.