diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 657ba3c..74010b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..ef64c66 --- /dev/null +++ b/.npmrc @@ -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 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..8af8d8c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +**/dist/ +**/node_modules/ +data/ +pnpm-lock.yaml +*.md diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..efe63f1 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "printWidth": 100, + "singleQuote": true, + "trailingComma": "all", + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/CLAUDE.md b/CLAUDE.md index 4d541cc..8b6ee99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,7 @@ 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 ` 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 `, 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") @@ -62,6 +63,8 @@ A hub session behaves like a native `claude` session started in the same directo 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). @@ -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 diff --git a/README.md b/README.md index a953990..d8e91bd 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/deploy/.env.example b/deploy/.env.example index 235c8a3..ec76a6a 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -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. diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 3ac1ef1..53ab570 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -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/ @@ -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 @@ -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"] diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..f0e67db --- /dev/null +++ b/eslint.config.js @@ -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 @@ -45,9 +51,19 @@ {summarizeInput(approval.toolName, approval.input)}
{JSON.stringify(approval.input, null, 2)}
+ {#if suggestions.length} +
+ CLI suggests + {#each suggestions as sg, i (i)} + {sg} + {/each} +
+ {/if}
- + {#if showDeny} { + if (!scroller || loadingEarlier) return; + loadingEarlier = true; + const before = scroller.scrollHeight; + const prevTop = scroller.scrollTop; + try { + await loadEarlier(sessionId); + await tick(); + // Keep the viewport anchored: prepended content grows scrollHeight from the top. + if (scroller) scroller.scrollTop = prevTop + (scroller.scrollHeight - before); + } catch (e) { + showToast(`Load earlier failed: ${(e as Error).message}`); + } finally { + loadingEarlier = false; + } + } $effect(() => { subscribeSession(sessionId); @@ -70,6 +90,12 @@
+ {#if historyMeta?.hasMoreBefore} + + {/if} + {#each timeline as item (item.id)} {#if item.kind === 'message'} @@ -102,8 +128,7 @@ ? 'Queue a follow-up (runs after this turn, Enter to send)…' : 'Message the agent (Enter to send, Shift+Enter for newline)'} bind:value={composer} - onkeydown={onKeydown} - > + onkeydown={onKeydown}>
{#if busy} @@ -133,6 +158,15 @@ flex-direction: column; gap: 10px; } + .load-earlier { + align-self: center; + padding: 5px 14px; + font-size: 12.5px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-dim); + } .streaming { position: relative; background: var(--bg-panel); diff --git a/web/src/components/DoneMarker.svelte b/web/src/components/DoneMarker.svelte index 187e587..952f80d 100644 --- a/web/src/components/DoneMarker.svelte +++ b/web/src/components/DoneMarker.svelte @@ -15,7 +15,9 @@ const label = $derived.by(() => { switch (reason) { case 'complete': - return formatCost(usage?.costUsd) ? `turn complete · ${formatCost(usage?.costUsd)}` : 'turn complete'; + return formatCost(usage?.costUsd) + ? `turn complete · ${formatCost(usage?.costUsd)}` + : 'turn complete'; case 'interrupted': return 'interrupted'; case 'killed': diff --git a/web/src/components/Launcher.svelte b/web/src/components/Launcher.svelte new file mode 100644 index 0000000..e7460f0 --- /dev/null +++ b/web/src/components/Launcher.svelte @@ -0,0 +1,486 @@ + + +
+
+

Let's build.

+

Point a session at a project, optionally isolate it, and give it a task.

+ + {#if engines.length && !availableEngines.length} +
+ No engine is available. Install and authenticate a CLI (e.g. claude login), + then reload. +
+ {/if} + + {#if recentProjects.length} +
+ Recent projects +
+ {#each recentProjects as p (p)} + + {/each} +
+
+ {/if} + + + + {#if showBrowse && browseData} +
+
+ {#if browseData.parent !== null || browseData.path} + + {/if} + {browseData.path || '(workspace roots)'} +
+
+ {#each browseData.entries as entry (entry.path)} +
+ + +
+ {:else} +

no subdirectories

+ {/each} +
+ {#if browseData.path} + + {/if} +
+ {/if} + + {#if git?.isRepo} +
+ on branch: {git.currentBranch ?? '(detached)'} + + {#if useWorktree} + + {/if} +
+ {/if} + + + +
+ + {#if showAdvanced} +
+ {#if showEnginePicker} + + {/if} + + + + + + +
+ {/if} +
+ + {#if error}

{error}

{/if} + +
+ +
+
+
+ + diff --git a/web/src/components/LoginGate.svelte b/web/src/components/LoginGate.svelte index afc45ab..70977fe 100644 --- a/web/src/components/LoginGate.svelte +++ b/web/src/components/LoginGate.svelte @@ -30,12 +30,7 @@

agentic-cli-hub

Enter the access token (see AUTH_TOKEN in the server environment or startup log).

- + {#if error}

{error}

{/if}
diff --git a/web/src/components/NewSessionDialog.svelte b/web/src/components/NewSessionDialog.svelte deleted file mode 100644 index ff27548..0000000 --- a/web/src/components/NewSessionDialog.svelte +++ /dev/null @@ -1,308 +0,0 @@ - - -
e.target === e.currentTarget && close()} - onkeydown={(e) => e.key === 'Escape' && close()} - role="presentation" -> -
-

New session

- - - - - - {#if showBrowse && browseData} -
-
- {#if browseData.parent !== null || browseData.path} - - {/if} - {browseData.path || '(workspace roots)'} -
-
- {#each browseData.entries as entry (entry.path)} -
- - -
- {:else} -

no subdirectories

- {/each} -
- {#if browseData.path} - - {/if} -
- {/if} - -
- Permission mode - {#each PERMISSION_MODES as mode (mode.value ?? 'inherit')} - - {/each} -
- - - - - - {#if error}

{error}

{/if} - -
- - -
-
-
- - diff --git a/web/src/components/SessionSidebar.svelte b/web/src/components/SessionSidebar.svelte index a8200fb..bdf3d20 100644 --- a/web/src/components/SessionSidebar.svelte +++ b/web/src/components/SessionSidebar.svelte @@ -1,21 +1,51 @@