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
22 changes: 19 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@ on:
pull_request:
branches: [main]

# Least privilege: CI only needs to read the repository.
permissions:
contents: read

# Cancel superseded runs on the same ref (saves CI minutes on rapid pushes).
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

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

Expand All @@ -23,10 +33,16 @@ jobs:
# 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
# Only @hub/shared's dist is needed downstream (server/web typecheck + tests
# consume it). Production artifact builds (server dist, web Vite bundle) are
# left to the deploy image, so CI stays fast during development.
- run: pnpm --filter @hub/shared build

- run: pnpm typecheck

- run: pnpm lint

- run: pnpm format:check

# Keyless: the live claude CLI test self-skips unless CLAUDE_LIVE=1 (never set here).
- run: pnpm test
11 changes: 11 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Vite loads its TS config from a temp dir (web/node_modules/.vite-temp); under
# pnpm the Svelte plugin must be resolvable from the workspace root for a fresh
# (Docker/CI) install, not just a warm local one. Keep pnpm's default hoist
# patterns for eslint/prettier plugins alongside it.
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=@sveltejs/vite-plugin-svelte

# `pnpm deploy` (used in deploy/Dockerfile to build the self-contained server
# tree) needs legacy mode under pnpm v10; our shared package isn't injected.
force-legacy-deploy=true
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
**/dist/
**/node_modules/
data/
pnpm-lock.yaml
*.md
7 changes: 7 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all",
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,16 @@ The spine is: **engine adapter normalizes a CLI stream into `NormalizedEvent`s -
- **`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.
- **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.

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

The CLI's `permission_suggestions` (allow-rules it proposes for a prompt) ride through on `approval_request.permissionSuggestions` (loosely typed; shape not pinned) and render as **informational** chips in the ApprovalCard. The hub does not persist rules from them (that would mutate `~/.claude`); allow/deny is unchanged.

### 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).
Expand All @@ -73,12 +76,12 @@ Permission policy is **delegated to the engine**, not decided by the hub. The Cl

### 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).
`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. Event replay is windowed: a fresh subscribe replays the most recent N events (`listRecent`) and older history pages backward on demand (`listBefore`, keyset on the `(session_id, id)` index); forward catch-up (`listAfter`) is unchanged.

### 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.
- **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 Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ See the install steps in [`deploy/agentic-cli-hub.service`](deploy/agentic-cli-h
| `HOST` / `PORT` | `127.0.0.1` / `3000` | Bind address |
| `DATA_DIR` | `./data` | SQLite session history |
| `WORKSPACE_ROOTS` | `$HOME` | Colon-separated dirs sessions may target as cwd |
| `WORKTREE_ROOT` | `$DATA_DIR/worktrees` | Where isolated per-session git worktrees are created |
| `ENGINES` | `claude-code,echo` | Engine adapters to enable |
| `CLAUDE_BIN` | `claude` on PATH | Engine binary override |
| `ENGINE_IDLE_TIMEOUT_MS` | `1800000` | Kill idle engine processes (sessions resume seamlessly) |
Expand Down
3 changes: 3 additions & 0 deletions deploy/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ DATA_DIR=./data
# Colon-separated list of directories sessions may target as their working
# directory. Defaults to $HOME. In docker this is the /workspace mount.
WORKSPACE_ROOTS=
# Where isolated per-session git worktrees are created. Defaults to
# $DATA_DIR/worktrees (persisted on the hub-data volume in docker).
WORKTREE_ROOT=

# --- engines ------------------------------------------------------------
# Comma-separated engine ids to enable.
Expand Down
11 changes: 9 additions & 2 deletions deploy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ WORKDIR /app
RUN corepack enable \
&& apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json .npmrc ./
COPY shared/package.json shared/
COPY server/package.json server/
COPY web/package.json web/
Expand All @@ -22,7 +22,11 @@ FROM node:22-bookworm-slim
# Pin the engine CLI version; qualify new versions with `pnpm --filter
# @hub/server spike` before bumping (the CLI's headless interface can change).
ARG CLAUDE_CODE_VERSION=2.1.205
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
# git is required at runtime: isolated-worktree sessions shell out to it, and
# the Claude CLI uses it for repo context.
RUN apt-get update && apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
&& useradd -m -u 1001 hub
WORKDIR /opt/hub
COPY --from=build /out/server ./server
Expand All @@ -35,6 +39,9 @@ ENV NODE_ENV=production \
DATA_DIR=/data \
WEB_DIST_DIR=/opt/hub/web/dist \
WORKSPACE_ROOTS=/workspace
# Own /data (SQLite + isolated worktrees) as the runtime user; a named volume
# mounted here inherits this ownership on first use.
RUN mkdir -p /data && chown hub:hub /data
USER hub
EXPOSE 3000
CMD ["node", "server/dist/index.js"]
37 changes: 37 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import svelte from 'eslint-plugin-svelte';
import prettier from 'eslint-config-prettier';
import globals from 'globals';

export default tseslint.config(
{ ignores: ['**/dist/', '**/node_modules/', 'data/', 'web/dist/'] },

js.configs.recommended,
tseslint.configs.recommended,
svelte.configs.recommended,

{
languageOptions: {
globals: { ...globals.node, ...globals.browser },
},
rules: {
// Allow intentionally-unused args/vars prefixed with _, and unbound catches.
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' },
],
},
},

// Svelte <script lang="ts"> needs the TS parser.
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: { parser: tseslint.parser },
},
},

// Turn off formatting-related rules; Prettier owns formatting.
prettier,
);
42 changes: 42 additions & 0 deletions handoff3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Handoff #3 — Session-management completeness (fork, archive, approvals, pagination)

**Branch:** `claude/session-mgmt-completeness` · **Status:** built, typechecked, and verified end-to-end (21 keyless tests + a live claude-code fork/resume drive). Continues the `handoff2.md` next-steps. No new engine, no scheduling: this rounds out the session lifecycle so a hub session is a fully manageable "terminal window" (README §6 req #4: list / resume / **fork** / **archive** / **search**).

## What changed

One additive DB migration (id:4: `parent_session_id`, `fork_pending`); the other three workstreams ride in existing columns / JSON payloads.

1. **Session fork (`--fork-session`)** — `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. On the fork's first turn that flag adds `--fork-session` alongside `--resume <parent>`, so the CLI replays the parent transcript but writes into a *new* engine session id (consumed on the first `engine_meta`). The parent is untouched; only a session that has run is forkable. `SessionDto.parentId` records lineage. UI: a fork button in the sidebar row.
2. **Archived-sessions view** — backend already supported archive end-to-end, so this is UI wiring: a "Show archived" toggle (lazy `api.sessions(true)`), an archive/unarchive row action, a render-time `archivedAt` filter, plus a title **search** filter.
3. **Approval affordances** — the CLI's `permission_suggestions` now ride on `approval_request.permissionSuggestions` and render as **informational** chips in the ApprovalCard (defensive rendering; loosely typed). The hub deliberately does *not* persist allow-rules from them (that would mutate `~/.claude`); allow/deny is unchanged.
4. **Replay pagination** — a fresh subscribe replays the most recent N events (`listRecent`, default 500) and sends a `replay_meta {hasMoreBefore, oldestEventId}` frame; older history pages backward over REST (`/api/sessions/:id/events?beforeId&limit` -> `listBefore`, keyset on the `(session_id, id)` index). Forward catch-up on reconnect (`afterEventId`) is unchanged, preserving the atomic replay-then-live invariant. UI: a "Load earlier" control with scroll anchoring.

## How it was verified

- `pnpm build` / `pnpm typecheck` / `pnpm test` green: **21 keyless** passing (echo engine), web bundle unchanged at 233 KB (79 KB gzip), svelte-check 0 errors.
- New keyless e2e: fork copies the parent's events, runs its own turn, and leaves the parent's log untouched; bounded initial replay returns only the recent window with `hasMoreBefore`, and older events page in over REST.
- Migration 4 applied cleanly to the real dev DB (11 existing sessions backfilled `parent_session_id=null`, `fork_pending=0`).
- **Live** (gated, `CLAUDE_LIVE=1`): forking a real Claude session minted a *new* engine session id and recalled the parent's planted codeword, confirming `--fork-session` carried the transcript while the parent id stayed intact.

## Run & test locally

See `handoff2.md` for the full toolchain note (corepack/pnpm). Servers already run on :3000 / :5173, login token from the server log.

```bash
pnpm --filter @hub/shared build # server/web import shared's dist — build it first
pnpm build && pnpm typecheck && pnpm test # 21 keyless tests
CLAUDE_LIVE=1 pnpm --filter @hub/server test # live claude drives (~cents)
```

What to exercise on this branch:
- **Fork**: run a turn in a session, then click the fork button (⑂). The new session opens with the prior scrollback and continues the same conversation on its own branch; the parent is unchanged. (Disabled until the session has run once.)
- **Archive / search**: archive a session (📁), toggle **🗄 Show archived** to see it, unarchive (↩). Type in the search box to filter by title.
- **Approval affordances**: in a `manual`-mode session, when a tool needs approval the card shows "CLI suggests" chips for context.
- **Pagination**: in a session with a long history, only the recent window loads; click **Load earlier** to page older messages in.

## Not done / next

- **Persist an allow-rule from a suggestion chip** (click-to-allow-always) — deliberately out of scope; needs extra protocol/CLI surface and writes to `~/.claude`.
- **Roadmap** (still open from handoff2): Copilot CLI / Codex adapters (Codex is installed locally and feasible via `codex exec --json` + sandbox modes; a differently-shaped adapter that stress-tests the contract), scheduling/automation.
- Fork lineage is stored (`parentId`) but not yet surfaced in the UI (e.g. a "forked from" hint).
- Pagination back-paging uses a full-page heuristic for `hasMoreBefore`; a final partial page can leave one no-op "Load earlier" click.
13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,25 @@
"build": "pnpm -r build",
"test": "pnpm -r test",
"typecheck": "pnpm -r typecheck",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"start": "node server/dist/index.js"
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild"
]
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.6.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.20.0",
"globals": "^17.7.0",
"prettier": "^3.9.4",
"prettier-plugin-svelte": "^4.1.1",
"typescript-eslint": "^8.63.0"
}
}
Loading
Loading