Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@ pnpm build # builds all three; shared builds first (topological)
pnpm typecheck # tsc --noEmit across all packages (svelte-check for web)
pnpm test # runs each package's test (only server has real tests)
pnpm dev # server (:3000) + web Vite (:5173) in parallel
pnpm lint # eslint . (flat config in eslint.config.js; extends prettier)
pnpm format # prettier --write . (format:check is the CI gate)
pnpm start # node server/dist/index.js (serves built web too; run after build)
```

CI (`.github/workflows`) gates on `format:check`, `lint`, `typecheck`, `build`, and `test`; run them locally before pushing.

Important ordering: `server` and `web` import from `@hub/shared`'s built `dist/`. Before `pnpm dev`, build shared at least once (`pnpm --filter @hub/shared build`), or run it in watch (`pnpm --filter @hub/shared dev`). A stale `shared/dist` is the usual cause of phantom type errors after editing shared.

Server-specific (run from repo root):
Expand All @@ -41,7 +46,7 @@ The spine is: **engine adapter normalizes a CLI stream into `NormalizedEvent`s -

- `shared/` (`@hub/shared`) is the contract package: the `NormalizedEvent` schema, the WS `ClientMsg`/`ServerMsg` protocol, and REST DTOs. Both other packages depend on it. Change a type here and rebuild before the change is visible downstream.
- `server/` (`@hub/server`) is Fastify + `ws`, backed by better-sqlite3.
- `web/` (`@hub/web`) is a Svelte 5 SPA (no SvelteKit): plain Vite, markdown via `marked` + `DOMPurify` + `highlight.js`.
- `web/` (`@hub/web`) is a Svelte 5 SPA (no SvelteKit): plain Vite, markdown via `marked` + `DOMPurify` + `highlight.js`. Styling is dependency-free: semantic design tokens in `web/src/app.css` (`--surface-*`, `--status-*`, spacing/type/radius/motion scales; a light theme would be a `[data-theme]` override block) plus scoped component styles keyed off `data-*` attributes. Shared primitives live in `web/src/components/ui/` (`Icon` inline-SVG set, `Toasts` + `lib/toasts.ts` with severities/undo actions, `ConfirmDialog` + `lib/confirm.ts` promise API over native `<dialog>`, `ShortcutsHelp` + `lib/shortcuts.ts` global hotkeys). The sidebar collapses to an overlay drawer at <= 900px (drawer state owned by `App.svelte`). Offline behavior: `ws.ts send()` returns a boolean, sends are disabled while disconnected, and optimistic user messages live in `stores.ts pendingSends` (outside `eventsBySession`, reconciled by FIFO text match against the server echo; interrupt clears them with a restore toast).

### Event flow and key types

Expand All @@ -56,6 +61,8 @@ The spine is: **engine adapter normalizes a CLI stream into `NormalizedEvent`s -
- **Two state axes** (both on `SessionDto`): interaction `status` (idle/running/awaiting_approval/error) and engine liveness `engineState` (`new` -> `live` on spawn -> `hibernated` on idle-dispose/stop/boot-reconcile, or `dead` on an unexpected exit) plus `enginePid`. `stopEngine()` (WS `stop_engine`) tears the process down now while keeping the session resumable; a crash mid-turn also closes the open turn with a `done{error}`.
- **Resume**: `engine_session_id` is updated from *every* engine `init` (`engine_meta`), not just the first, because some CLI versions mint a new session id per resume and only the latest resumes correctly. The hibernated/dead engine is respawned with `--resume <engineSessionId>` on the next message.
- **Fork** (`POST /api/sessions/:id/fork` -> `SessionManager.fork`): copies the parent's config + event log (`EventsRepo.copyEvents`) into a new session that seeds the parent's `engine_session_id` and sets a one-shot `fork_pending` flag (migration 4). On the fork's first `ensureRun` that flag adds `--fork-session` alongside `--resume <parent>`, so the CLI replays the parent transcript but writes into a *new* engine session id; `fork_pending` is consumed on the first `engine_meta`. The parent is untouched. Only a session that has run (non-null `engineSessionId`) is forkable.
- **Isolated git worktree** (the New Session launcher, `web/src/components/Launcher.svelte`): a `CreateSessionRequest` with `worktree: true` (+ optional base `branch`) makes `SessionManager.create` call `addWorktree` (`server/src/fs/git.ts`) to check out a fresh `hub/<id8>` branch into `<worktreeRoot>/<id>`, and runs the session there instead of the source repo, so its edits never touch the source working tree. `worktreeRoot` defaults to `<dataDir>/worktrees` (`WORKTREE_ROOT`). Requires a git-repo cwd; failures raise `HubError('worktree_failed')`. `delete` best-effort removes the worktree and its `hub/…` branch (only ever a hub-managed branch, never a user branch). The launcher populates its branch dropdown from `GET /api/git/info` (`gitInfo`, best-effort, never throws for a non-repo cwd).
- **Archive**: a session is soft-hidden via `PATCH /api/sessions/:id {archived:true}` (sets `archivedAt`); `GET /api/sessions` omits archived rows unless `?includeArchived=1`. Archive is a list-visibility flag only, it does not touch the engine or event log; `delete` is the hard removal (cascades events).

### Permission control (the "safety dial")

Expand All @@ -80,7 +87,7 @@ The CLI's `permission_suggestions` (allow-rules it proposes for a prompt) ride t

### Transport

- **REST** (`server/src/api/routes.ts`): sessions CRUD, `/api/engines`, `/api/fs/{validate,browse}`, `/api/health`. Bearer-token auth via an `onRequest` hook (`health` exempt). `HubError` maps to status codes (`not_found` -> 404, `session_busy` -> 409, else 400).
- **REST** (`server/src/api/routes.ts`): sessions CRUD (`PATCH` toggles `archived`, `POST :id/fork`), `/api/engines`, `/api/fs/{validate,browse}`, `/api/git/info`, `/api/health`. Bearer-token auth via an `onRequest` hook (`health` exempt). `HubError` maps to status codes (`not_found` -> 404, `session_busy` -> 409, else 400, so `worktree_failed` -> 400).
- **WebSocket** (`server/src/api/ws.ts`, protocol in `shared/src/protocol.ts`): one socket per tab at `/ws`, all sessions multiplexed. **Auth is by first message, never a URL token** (keeps it out of access logs). After auth, `subscribe {sessionId, afterEventId?, limit?}` replays persisted history (synchronous better-sqlite3 read, so no live event interleaves), then marks the subscription live. With `afterEventId` it is forward catch-up (full); omitted, it is a bounded initial load of the most recent `limit` events followed by a `replay_meta {hasMoreBefore, oldestEventId}` frame the client uses to page older history over REST (`/api/sessions/:id/events?beforeId&limit`). The web client (`web/src/lib/ws.ts`) reconnects with backoff and re-subscribes every known session from its last-seen event id.

### Working-directory safety
Expand All @@ -89,6 +96,6 @@ The CLI's `permission_suggestions` (allow-rules it proposes for a prompt) ride t

## Configuration and deployment

Config is env-only (`server/src/config.ts`); see the table in `README.md` §10. Key vars: `AUTH_TOKEN` (generated + logged per boot if unset), `WORKSPACE_ROOTS` (colon-separated, default `$HOME`), `ENGINES` (default `claude-code,echo`), `CLAUDE_BIN`, `ENGINE_IDLE_TIMEOUT_MS`, `APPROVAL_TIMEOUT_MS` (0 = wait forever). `deploy/` holds a Dockerfile (pins the claude CLI version via ARG), docker-compose (mounts host `~/.claude` + `~/.claude.json` for auth; the platform stores no keys), and a systemd unit.
Config is env-only (`server/src/config.ts`); see the table in `README.md` §10. Key vars: `AUTH_TOKEN` (generated + logged per boot if unset), `WORKSPACE_ROOTS` (colon-separated, default `$HOME`), `ENGINES` (default `claude-code,echo`), `CLAUDE_BIN`, `ENGINE_IDLE_TIMEOUT_MS`, `APPROVAL_TIMEOUT_MS` (0 = wait forever), `DATA_DIR` (SQLite + default worktree parent, default `./data`), `WORKTREE_ROOT` (isolated-worktree parent, default `<DATA_DIR>/worktrees`). `deploy/` holds a Dockerfile (pins the claude CLI version via ARG), docker-compose (mounts host `~/.claude` + `~/.claude.json` for auth; the platform stores no keys), and a systemd unit.

Keep the workspace mount path stable across deploys: the claude CLI keys its transcript store by project path, so moving it breaks session resume. When bumping the pinned CLI version, run `pnpm --filter @hub/server spike` first and diff the output against `server/test/fixtures/*.ndjson` (captured from a known-good live run; `normalize.test.ts` asserts against them).
Loading
Loading