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
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# Reads the pinned pnpm from package.json's "packageManager" field.
- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

# Builds native deps (better-sqlite3, esbuild) per the onlyBuiltDependencies allowlist.
- run: pnpm install --frozen-lockfile

# shared must build before server/web typecheck (they consume its dist/).
- run: pnpm build

- run: pnpm typecheck

# Keyless: the live claude CLI test self-skips unless CLAUDE_LIVE=1 (never set here).
- run: pnpm test
91 changes: 91 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# CLAUDE.md

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

## What this is

A self-hosted web platform that turns locally installed CLI coding agents (Claude Code first; Copilot CLI and Codex planned) into a browser-accessible, session-managed workbench. The platform never re-implements agent logic: it spawns real CLI processes in their headless/stream-json mode, normalizes their event streams into one internal schema, and drives a web UI plus a persistent history store from it. Single-tenant, Linux-first. See `README.md` for the full mission/scope and `handoff1.md` for the current build state and next-steps.

## Commands

pnpm workspace monorepo (`shared`, `server`, `web`), Node >= 20, pnpm >= 10.

```bash
pnpm install
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
```

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):

```bash
pnpm --filter @hub/server dev # tsx watch, AUTH_TOKEN logged if unset
pnpm --filter @hub/server test # vitest run (keyless; echo engine covers the flow)
pnpm --filter @hub/server exec vitest run test/normalize.test.ts # single test file
pnpm --filter @hub/server exec vitest -t 'interrupt' # single test by name
CLAUDE_LIVE=1 pnpm --filter @hub/server test # gated live claude CLI test (spends a few cents)
pnpm --filter @hub/server spike # protocol probe against the real CLI
```

Web dev proxies `/api` and `/ws` to the server (override backend with `HUB_SERVER=...`). Open `http://localhost:5173` and log in with the token from the server log. There is no test suite in `web`; its behavior is covered by the server e2e test driving the same protocol.

## Architecture

The spine is: **engine adapter normalizes a CLI stream into `NormalizedEvent`s -> `SessionRuntime` (state machine) applies approval policy, persists each event to SQLite, and fans out over an in-process bus -> WebSocket layer replays/streams to the browser -> Svelte stores render the timeline.** Understanding the `NormalizedEvent` union in `shared/src/events.ts` and the two protocols in `shared/src/protocol.ts` (WS) and `shared/src/api.ts` (REST DTOs) unlocks the whole system, since both server and web are written against them.

### Packages

- `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`.

### Event flow and key types

- **`NormalizedEvent`** (`shared/src/events.ts`): `message | tool_call | tool_result | approval_request | approval_resolved | done | engine_meta`. Every adapter emits only these; the UI and history know nothing about any engine's native format.
- **Streaming deltas vs. persisted messages**: streaming text is ephemeral (`stream_delta` WS frames, never persisted). The final persisted `message` event carries the same `messageKey` as its deltas so the client swaps the streaming buffer for the final text without duplication (see `web/src/lib/stores.ts` `appendEvent`/`appendDelta`). Adapters set `messageKey`; the Claude adapter uses `${messageId}:${textBlockIndex}`.
- **The bus** (`server/src/bus.ts`) is a plain EventEmitter; one set of listeners per connected browser tab (max listeners set to 0). `SessionRuntime` emits `event`/`delta`/`status`/`session_upsert`/`session_deleted`; `api/ws.ts` translates those into `ServerMsg` frames for subscribed sockets.

### Session lifecycle

- **`SessionManager`** (`server/src/sessions/SessionManager.ts`) owns CRUD and the map of live `SessionRuntime`s. `reconcileOnBoot()` runs at startup: it SIGKILLs orphaned engine process groups from a previous run (verifying via `/proc/<pid>/cmdline` that the pid is still an engine, since pids recycle), closes dangling approvals as `resolvedBy: 'stale'`, caps cut-off turns with a `done{killed}`, normalizes statuses back to idle, and marks reaped engines `engineState: 'hibernated'` (resumable).
- **`SessionRuntime`** (`server/src/sessions/SessionRuntime.ts`) is the per-session state machine: `idle -> running <-> awaiting_approval -> idle | error`, one in-flight turn at a time. It lazily starts the engine on the first message (`ensureRun`), relays each engine-surfaced `approval_request` to the user (policy lives in the engine, not the hub), persists every complete event, and arms an idle timer that disposes the engine while leaving the session resumable. A `user_message` while `busy` is **queued** (bounded at `MAX_QUEUE`) and drained one turn at a time on each `done`; interrupt/stop/dispose clear the queue.
- **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.

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

A hub session behaves like a native `claude` session started in the same directory: per-session config passes through to the CLI and inherits `~/.claude` when unset. The canonical control is `permissionMode` -> `--permission-mode` (`manual`/`acceptEdits`/`plan`/`auto`/`dontAsk`/`bypassPermissions`); `model` -> `--model` and `settings` -> `--settings` also pass through. The legacy 3-way `approvalMode` (`approve_all`/`approve_writes`/`full_auto`) is now only a preset alias mapped in `server/src/approvals/permissionMode.ts` (`presetToPermissionMode`).

Permission policy is **delegated to the engine**, not decided by the hub. The Claude CLI applies `--permission-mode` natively and only routes tools it wants asked to `--permission-prompt-tool stdio`; the hub relays every surfaced `approval_request` to the user. There is no hub-side tool-name policy anymore (the old `READ_ONLY_TOOLS` set and its spoofing sharp edge are gone). Consequence, and intended for CLI parity: host `PreToolUse` hooks / `permissions.allow` rules can auto-allow tools so the hub never sees them. The echo stub emulates a mode itself via `permissionModeDisposition` (ask/allow/deny) to stay a faithful template.

### Engine adapters

`server/src/engines/types.ts` defines the `EngineAdapter` / `EngineRun` contract. An adapter emits `approval_request` only for tools its permission mode wants asked, and blocks the tool until `respondToApproval()` is called; the hub relays it to the user (auto-allow/deny is the engine's job, not the hub's).

- **`ClaudeCodeAdapter`** (`server/src/engines/claude/`) spawns one long-lived `claude -p --input-format stream-json --output-format stream-json --verbose --permission-prompt-tool stdio --include-partial-messages [--permission-mode <m>] [--model <m>] [--settings <s>] [--resume <id>]` per active session, `detached: true` so `process.kill(-pid, ...)` tears down claude and everything it spawned. `ndjson.ts` frames stdout; `normalize.ts` maps CLI messages to `NormalizedEvent`s and returns `[]` for unknown message types (`thinking_tokens`, `rate_limit_event`, ...); approvals arrive as `can_use_tool` control_requests answered over stdin. Partial-message `stream_event` text deltas are mapped to `onDelta` (keyed `${messageId}:${textBlockIndex}` so the final persisted `message` replaces the streaming buffer). Interrupt sends an `interrupt` control_request, then escalates to SIGTERM/SIGKILL on a timer.
- **`EchoAdapter`** (`server/src/engines/echo/`) is a real, process-less adapter (tool call -> approval-or-auto per permission mode -> result -> streamed markdown -> done). It powers the keyless test suite and is the minimal template for a new adapter.
- **Registration**: add engines in `server/src/engines/registry.ts` and to the `ENGINES` env default. A new adapter must pass `server/test/adapter-contract.ts` (call `adapterContract(factory, {cwd})` from a `describe` block). Check `supportsInteractiveApproval`: engines without interactive gating should degrade to pre-run permission profiles.
- **Env scrubbing gotcha**: `SCRUBBED_ENV` in `ClaudeCodeAdapter.ts` strips `CLAUDECODE`, `CLAUDE_CODE_SESSION_ID`, etc. from the child. Without this, running the hub *inside* a Claude Code session (common in dev) makes the spawned child join the parent's session. Preserve this when touching spawn logic.

### Persistence

`server/src/db/database.ts` opens SQLite (WAL, foreign keys on) with embedded, id-ordered migrations (no external `.sql` files, so `dist` is self-contained). Two tables: `sessions` and `events` (events cascade-delete with their session). Event `id` is the replay cursor. `EventsRepo`/`SessionsRepo` in `db/repos.ts` are the only DB access; keep SQL there. There is no pagination on event replay yet (long sessions replay fully).

### 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).
- **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}` replays persisted history from the cursor (synchronous better-sqlite3 read, so no live event interleaves), then marks the subscription live. 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

`server/src/fs/workspace.ts` `validateCwd()` realpaths a candidate cwd (resolving symlinks first) and requires it to sit within `WORKSPACE_ROOTS`. Every session cwd goes through this. A `bypassPermissions` session can do anything the server's OS user can within its cwd, hence the README's advice to run the service as a dedicated user with narrow `WORKSPACE_ROOTS`.

## 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.

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).
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Key mechanism: each engine adapter normalizes the engine's native event stream (
| 2 | Engine: **Claude Code first**, adapter interface designed for Copilot CLI & Codex | Claude Code has the most mature headless mode; others follow in fast-follow releases |
| 3 | Web chat UI | Streaming responses, markdown rendering, visible tool-call progression with collapsible detail |
| 4 | Session management | List / resume / rename / delete; history persisted across restarts; resume maps to engine-native resume where available |
| 5 | Approval broker | UI prompt for tool approvals in *approval mode*; toggle to *full auto* per session |
| 5 | Permission control | Per-session `--permission-mode` (ask / accept-edits / plan / bypass), inherited from `~/.claude` when unset; the engine decides what to surface and the UI prompts for it |
| 6 | Config mounting | Detect and use existing engine binaries + user configs; no keys stored by the platform |
| 7 | Repo targeting | Choose working directory per session; agentic `.md` repo picked up natively by the engine |

Expand Down Expand Up @@ -102,7 +102,7 @@ Key mechanism: each engine adapter normalizes the engine's native event stream (

- **Engine CLIs change their headless interfaces** → isolate all engine specifics in adapters; pin tested versions; contract tests per adapter.
- **Full-auto sessions doing damage** → full-auto requires explicit per-session opt-in, is visually distinct in the UI, and is constrained to the session's working directory; recommend running the service as a dedicated user.
- **Approval flows differ per engine** (some only support allow-lists, not interactive gating) → the approval broker degrades gracefully: interactive gating where supported, pre-run permission profiles where not.
- **Approval flows differ per engine** (some only support allow-lists, not interactive gating) → permission policy is delegated to the engine (Claude via `--permission-mode`); the hub relays whatever the engine surfaces to the user. Engines without interactive gating apply their mode themselves or degrade to pre-run permission profiles.
- **Scope creep toward "yet another agent framework"** → principle #2 is the tie-breaker: if a feature re-implements engine behavior, it's out.

## 9. Getting Started
Expand Down Expand Up @@ -161,7 +161,7 @@ See the install steps in [`deploy/agentic-cli-hub.service`](deploy/agentic-cli-h
```
shared/ @hub/shared — normalized event schema, WS protocol, API DTOs
server/ @hub/server — Fastify + WebSocket hub, SQLite event log,
engine adapters (claude-code, echo), approval broker
engine adapters (claude-code, echo), permission-mode policy
web/ @hub/web — Svelte 5 chat UI (tool timeline, approvals, sessions)
deploy/ Dockerfile, docker-compose.yml, systemd unit, .env.example
```
Expand Down
Loading
Loading