diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..657ba3c --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4d541cc --- /dev/null +++ b/CLAUDE.md @@ -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//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 ` 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 ] [--model ] [--settings ] [--resume ]` 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). diff --git a/README.md b/README.md index 58b402f..a953990 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 @@ -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 ``` diff --git a/handoff2.md b/handoff2.md new file mode 100644 index 0000000..d2772e0 --- /dev/null +++ b/handoff2.md @@ -0,0 +1,54 @@ +# Handoff #2 — CLI-parity sessions, engine lifecycle, streaming, CI + +**Branch:** `claude/session-cli-parity-lifecycle` · **PR:** [#2](https://github.com/Neverdecel/agentic-cli-hub/pull/2) (open, CI green) · **Status:** built, typechecked, and verified end-to-end (19 keyless tests + live claude-code drives for every change). Continues the `handoff1.md` next-steps. + +## Guiding principle established this round + +A hub session should behave exactly like launching `claude` yourself in that directory: it inherits `~/.claude` (and the cwd's project `.claude/`) by default, and you control config the same way the CLI does, not through a parallel system the hub invents. See `~/.claudep/plans/lets-continue-with-the-scalable-valiant.md` for the full design. + +## What changed (6 commits) + +1. **CI** (`.github/workflows/ci.yml`) — Node 20 + pinned pnpm via corepack; `install --frozen-lockfile` → build → typecheck → test. All keyless (the live test self-skips). +2. **Config parity + permission unification** — per-session `permissionMode` (`--permission-mode`), `model` (`--model`), `settings` (`--settings`), each inheriting `~/.claude` when unset. The old 3-way `approvalMode` is now a preset alias mapped in `server/src/approvals/permissionMode.ts` (`approve_all→manual`, `approve_writes→acceptEdits`, `full_auto→bypassPermissions`). **Permission policy is delegated to the engine** — the `ApprovalBroker`/`READ_ONLY_TOOLS` tool-name policy (and its spoofing sharp edge) is retired; the hub just relays whatever the CLI surfaces. DB migration 2. +3. **Engine liveness + stop control** — `SessionDto` gains `engineState` (`new`/`live`/`hibernated`/`dead`) + `enginePid`. New `stop_engine` WS control. A mid-turn crash closes the turn with `done{error}` instead of hanging. DB migration 3. UI: liveness dots, an engine indicator + "stop engine" button, and the permission dropdown shows the CLI modes. +4. **Bundle trim** — `highlight.js/lib/core` + a language subset: web bundle 1.11 MB → 229 KB (362 → 78 KB gzip). +5. **Queue mid-turn messages** — a `user_message` sent while busy is buffered (bounded) and drained on `done` instead of throwing `session_busy`. The web composer lets you queue a follow-up ("Queue" button). +6. **Claude streaming** — `--include-partial-messages`; `stream_event` text deltas map to `onDelta` (key `${messageId}:${textBlockIndex}`), so assistant text streams and the final message reconciles the buffer. + +## How it was verified + +- `pnpm install/build/typecheck/test` all green (19 passing, 2 live gated). +- Live drives against claude 2.1.205 (scratchpad drivers): `manual` asks for Write while `bypassPermissions` runs it silently; `--model sonnet` reaches the CLI; migration backfilled real sessions; engine `live→dead` on external SIGKILL (idle and mid-turn), `dead→live` respawn, `live→hibernated` on `stop_engine`; streaming deltas concatenate exactly to the final message. + +## Run & test locally + +Toolchain note: the repo pins `pnpm@10.33.0` via the `packageManager` field. If `pnpm` isn't on PATH, enable it through Node's corepack (Node 20/22 ship it) — e.g. `corepack enable`, or scoped to a user dir: `corepack enable --install-directory ~/.local/bin pnpm`. + +```bash +pnpm install +pnpm --filter @hub/shared build # server/web import shared's dist — build it first +pnpm build # or just build everything +pnpm --filter @hub/server dev # :3000 (AUTH_TOKEN logged if unset) +pnpm --filter @hub/web dev # :5173, proxies /api + /ws to :3000 +``` + +Open `http://localhost:5173` and log in with the token from the server log. + +```bash +pnpm test # 19 keyless tests (echo engine), no API key +CLAUDE_LIVE=1 pnpm --filter @hub/server test # live claude CLI integration (~cents) +``` + +What to exercise on this branch: +- **Permission modes** (New session dialog): `Ask for every tool` (manual) prompts before a Write; `Full auto (bypass)` runs tools with no prompt; `Inherit (~/.claude)` uses the directory's own default. The status-bar dropdown changes it per turn. +- **Model override**: set an optional model (e.g. `sonnet`) when creating a session; the status bar shows the model the engine actually used. +- **Engine liveness**: watch the sidebar dot — green `live` after the first message, grey `asleep` after the idle timeout or clicking **stop engine** in the status bar, red `crashed` if the engine dies. The next message respawns it. +- **Queueing**: while a turn runs the composer button reads **Queue**; a message sent then runs after the current turn instead of erroring. +- **Streaming**: assistant text now streams token-by-token. + +## Not done / next + +- **Approval affordances** (surface the CLI's `permission_suggestions` in the ApprovalCard) — not started. +- **Roadmap**: Copilot CLI / Codex adapters, session fork (`--fork-session`), scheduling. +- No pagination on event replay; no archived-sessions view in the UI (API supports it). +- Exact semantics of the `auto` / `dontAsk` permission modes under `--permission-prompt-tool` are wired conservatively (auto→allow, dontAsk→deny) but only `manual`/`acceptEdits`/`bypassPermissions`/`plan` were exercised live. diff --git a/server/src/api/ws.ts b/server/src/api/ws.ts index 8f14ba0..d28b53b 100644 --- a/server/src/api/ws.ts +++ b/server/src/api/ws.ts @@ -135,6 +135,9 @@ export function attachWebSocket(server: HttpServer, deps: WsDeps): WebSocketServ case 'interrupt': void deps.manager.runtime(msg.sessionId).interrupt(); return; + case 'stop_engine': + deps.manager.runtime(msg.sessionId).stopEngine(); + return; } } diff --git a/server/src/approvals/ApprovalBroker.ts b/server/src/approvals/ApprovalBroker.ts deleted file mode 100644 index e17f143..0000000 --- a/server/src/approvals/ApprovalBroker.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ApprovalMode } from '@hub/shared'; - -/** - * Policy for tool approvals. The adapter surfaces every permission request; - * this broker decides whether it can be auto-allowed or must ask the user. - * Denials only ever come from the user. - */ - -const READ_ONLY_TOOLS = new Set([ - 'Read', - 'Glob', - 'Grep', - 'WebFetch', - 'WebSearch', - 'TodoWrite', - 'Task', - 'NotebookRead', - 'ListMcpResourcesTool', - 'ReadMcpResourceTool', - 'echo_probe_readonly', // used by tests -]); - -export type ApprovalDecision = 'allow' | 'ask_user'; - -export function decideApproval(mode: ApprovalMode, toolName: string): ApprovalDecision { - switch (mode) { - case 'full_auto': - return 'allow'; - case 'approve_writes': - return READ_ONLY_TOOLS.has(toolName) ? 'allow' : 'ask_user'; - case 'approve_all': - return 'ask_user'; - } -} diff --git a/server/src/approvals/permissionMode.ts b/server/src/approvals/permissionMode.ts new file mode 100644 index 0000000..cae5ace --- /dev/null +++ b/server/src/approvals/permissionMode.ts @@ -0,0 +1,51 @@ +import type { ApprovalMode, PermissionMode } from '@hub/shared'; + +/** + * Permission policy is delegated to the engine. The Claude CLI applies + * `--permission-mode` natively and only routes tools it wants asked to the + * `--permission-prompt-tool`; the hub simply relays those to the user. This + * module holds the two pure helpers that support that model: + * + * - presetToPermissionMode: map the legacy 3-way UI dial onto the CLI's modes. + * - permissionModeDisposition: how a mode treats a not-pre-allowed tool, for + * engines that must emulate the mode themselves (the echo stub). + * + * The old tool-name policy (READ_ONLY_TOOLS) is gone: it trusted tool *names* + * and could be spoofed, and the CLI already does the write/read distinction + * correctly via `acceptEdits`. + */ + +export function presetToPermissionMode(preset: ApprovalMode): PermissionMode { + switch (preset) { + case 'approve_all': + return 'manual'; + case 'approve_writes': + return 'acceptEdits'; + case 'full_auto': + return 'bypassPermissions'; + } +} + +export type PermissionDisposition = 'ask' | 'allow' | 'deny'; + +/** + * What a mode does with a tool that is NOT pre-allowed by settings. Mirrors the + * Claude CLI so the echo stub stays a faithful template: + * - manual / acceptEdits / plan / null -> ask via the permission prompt + * (acceptEdits auto-allows only *edit* tools, so a non-edit tool still asks) + * - auto / bypassPermissions -> allow without asking + * - dontAsk -> deny unless allowlisted + */ +export function permissionModeDisposition( + mode: PermissionMode | null | undefined, +): PermissionDisposition { + switch (mode) { + case 'auto': + case 'bypassPermissions': + return 'allow'; + case 'dontAsk': + return 'deny'; + default: + return 'ask'; + } +} diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 4880764..1c1d4b4 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -37,6 +37,35 @@ CREATE TABLE events ( payload TEXT NOT NULL ); CREATE INDEX idx_events_session ON events(session_id, id); +`, + }, + { + // Unify the permission dial with the CLI's --permission-mode, and add the + // per-session config passthroughs (requested model, settings). Backfill the + // new permission_mode from the legacy approval_mode preset. + id: 2, + sql: ` +ALTER TABLE sessions ADD COLUMN permission_mode TEXT; +ALTER TABLE sessions ADD COLUMN requested_model TEXT; +ALTER TABLE sessions ADD COLUMN settings TEXT; + +UPDATE sessions SET permission_mode = CASE approval_mode + WHEN 'approve_all' THEN 'manual' + WHEN 'approve_writes' THEN 'acceptEdits' + WHEN 'full_auto' THEN 'bypassPermissions' + ELSE NULL +END; +`, + }, + { + // Track engine-process liveness separately from the interaction status. + // Existing sessions with an engine session are resumable -> hibernated. + id: 3, + sql: ` +ALTER TABLE sessions ADD COLUMN engine_state TEXT NOT NULL DEFAULT 'new'; + +UPDATE sessions SET engine_state = + CASE WHEN engine_session_id IS NOT NULL THEN 'hibernated' ELSE 'new' END; `, }, ]; diff --git a/server/src/db/repos.ts b/server/src/db/repos.ts index ba3ee65..369dd76 100644 --- a/server/src/db/repos.ts +++ b/server/src/db/repos.ts @@ -1,7 +1,8 @@ import type Database from 'better-sqlite3'; import type { - ApprovalMode, + EngineState, NormalizedEvent, + PermissionMode, SessionDto, SessionEvent, SessionStatus, @@ -13,14 +14,17 @@ interface SessionRow { engine_id: string; engine_session_id: string | null; cwd: string; - approval_mode: string; + permission_mode: string | null; status: string; + engine_state: string; pid: number | null; created_at: number; updated_at: number; archived_at: number | null; last_cost_usd: number | null; model: string | null; + requested_model: string | null; + settings: string | null; } function toDto(r: SessionRow): SessionDto { @@ -30,13 +34,17 @@ function toDto(r: SessionRow): SessionDto { engineId: r.engine_id, engineSessionId: r.engine_session_id, cwd: r.cwd, - approvalMode: r.approval_mode as ApprovalMode, + permissionMode: (r.permission_mode as PermissionMode | null) ?? null, status: r.status as SessionStatus, + engineState: (r.engine_state as EngineState) ?? 'new', + enginePid: r.pid, createdAt: r.created_at, updatedAt: r.updated_at, archivedAt: r.archived_at, lastCostUsd: r.last_cost_usd, model: r.model, + requestedModel: r.requested_model, + settings: r.settings, }; } @@ -48,15 +56,18 @@ export class SessionsRepo { title: string; engineId: string; cwd: string; - approvalMode: ApprovalMode; + permissionMode: PermissionMode | null; + model: string | null; + settings: string | null; }): SessionDto { const now = Date.now(); this.db .prepare( - `INSERT INTO sessions (id, title, engine_id, cwd, approval_mode, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, 'idle', ?, ?)`, + `INSERT INTO sessions + (id, title, engine_id, cwd, permission_mode, requested_model, settings, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'idle', ?, ?)`, ) - .run(s.id, s.title, s.engineId, s.cwd, s.approvalMode, now, now); + .run(s.id, s.title, s.engineId, s.cwd, s.permissionMode, s.model, s.settings, now, now); return this.get(s.id)!; } @@ -83,8 +94,18 @@ export class SessionsRepo { this.touch(id); } - setApprovalMode(id: string, mode: ApprovalMode): void { - this.db.prepare('UPDATE sessions SET approval_mode = ? WHERE id = ?').run(mode, id); + setPermissionMode(id: string, mode: PermissionMode | null): void { + this.db.prepare('UPDATE sessions SET permission_mode = ? WHERE id = ?').run(mode, id); + this.touch(id); + } + + setRequestedModel(id: string, model: string | null): void { + this.db.prepare('UPDATE sessions SET requested_model = ? WHERE id = ?').run(model, id); + this.touch(id); + } + + setSettings(id: string, settings: string | null): void { + this.db.prepare('UPDATE sessions SET settings = ? WHERE id = ?').run(settings, id); this.touch(id); } @@ -103,6 +124,10 @@ export class SessionsRepo { this.db.prepare('UPDATE sessions SET pid = ? WHERE id = ?').run(pid, id); } + setEngineState(id: string, state: EngineState): void { + this.db.prepare('UPDATE sessions SET engine_state = ? WHERE id = ?').run(state, id); + } + setEngineSessionId(id: string, engineSessionId: string): void { this.db .prepare('UPDATE sessions SET engine_session_id = ? WHERE id = ?') diff --git a/server/src/engines/claude/ClaudeCodeAdapter.ts b/server/src/engines/claude/ClaudeCodeAdapter.ts index 234af95..7bef54b 100644 --- a/server/src/engines/claude/ClaudeCodeAdapter.ts +++ b/server/src/engines/claude/ClaudeCodeAdapter.ts @@ -3,7 +3,7 @@ import type { EngineDescriptor, NormalizedEvent } from '@hub/shared'; import type { EngineAdapter, EngineRun, StartOptions } from '../types.js'; import { NdjsonReader, ndjsonLine } from './ndjson.js'; import { normalizeCliMessage } from './normalize.js'; -import type { CliControlRequest, CliMessage } from './streamTypes.js'; +import type { CliControlRequest, CliMessage, CliStreamEvent } from './streamTypes.js'; export interface ClaudeAdapterOptions { /** Binary path override; defaults to `claude` on PATH. */ @@ -61,6 +61,13 @@ class ClaudeRun implements EngineRun { private exitCbs: ((info: { code: number | null; signal: string | null }) => void)[] = []; /** CLI request_id of a can_use_tool prompt, kept until we respond. */ private pendingApprovals = new Map(); + /** Partial-streaming state: current assistant message id + per-index text ordinals. + * The delta key must equal the final message's key (`${msgId}:${textBlockIndex}`), + * and normalize.ts counts only text blocks, so we map absolute block index -> + * text-only ordinal here. */ + private streamMsgId: string | undefined; + private textOrdinalByIndex = new Map(); + private nextTextOrdinal = 0; private interruptPending = false; private killTimer: ReturnType | undefined; private exited = false; @@ -72,8 +79,13 @@ class ClaudeRun implements EngineRun { '--output-format', 'stream-json', '--verbose', '--permission-prompt-tool', 'stdio', - ...extraArgs, + '--include-partial-messages', ]; + // Per-session config passthrough; each unset value inherits the ~/.claude default. + if (opts.permissionMode) args.push('--permission-mode', opts.permissionMode); + if (opts.model) args.push('--model', opts.model); + if (opts.settings) args.push('--settings', opts.settings); + args.push(...extraArgs); if (opts.resumeEngineSessionId) args.push('--resume', opts.resumeEngineSessionId); const env: NodeJS.ProcessEnv = { ...process.env, ...opts.env }; @@ -160,6 +172,11 @@ class ClaudeRun implements EngineRun { return; } + if (msg.type === 'stream_event') { + this.handleStreamEvent(msg as CliStreamEvent); + return; + } + const events = normalizeCliMessage(msg, { interruptPending: this.interruptPending }); for (const e of events) { if (e.type === 'done') this.interruptPending = false; @@ -167,6 +184,34 @@ class ClaudeRun implements EngineRun { } } + /** Map partial-message stream events to ephemeral text deltas (onDelta). */ + private handleStreamEvent(msg: CliStreamEvent): void { + const ev = msg.event; + switch (ev.type) { + case 'message_start': + this.streamMsgId = ev.message?.id; + this.textOrdinalByIndex.clear(); + this.nextTextOrdinal = 0; + return; + case 'content_block_start': + if (ev.content_block?.type === 'text' && ev.index !== undefined) { + this.textOrdinalByIndex.set(ev.index, this.nextTextOrdinal++); + } + return; + case 'content_block_delta': { + if (ev.delta?.type !== 'text_delta' || ev.index === undefined || !this.streamMsgId) return; + const ordinal = this.textOrdinalByIndex.get(ev.index); + const text = ev.delta.text ?? ''; + if (ordinal === undefined || !text) return; + const key = `${this.streamMsgId}:${ordinal}`; + for (const cb of this.deltaCbs) cb(key, text); + return; + } + default: + return; + } + } + sendUserMessage(text: string): void { this.write({ type: 'user', diff --git a/server/src/engines/claude/streamTypes.ts b/server/src/engines/claude/streamTypes.ts index aaefd7e..5948097 100644 --- a/server/src/engines/claude/streamTypes.ts +++ b/server/src/engines/claude/streamTypes.ts @@ -98,6 +98,21 @@ export interface CliControlResponse { }; } +/** + * Partial-message streaming (with --include-partial-messages). Wraps the + * Anthropic streaming events; we only consume text_delta on text content blocks. + */ +export interface CliStreamEvent { + type: 'stream_event'; + event: { + type: string; // message_start | content_block_start | content_block_delta | ... + index?: number; + message?: { id?: string }; + content_block?: { type?: string }; + delta?: { type?: string; text?: string }; + }; +} + export type CliMessage = | CliSystemInit | CliAssistantMessage @@ -105,4 +120,5 @@ export type CliMessage = | CliResult | CliControlRequest | CliControlResponse + | CliStreamEvent | { type: string; [k: string]: unknown }; diff --git a/server/src/engines/echo/EchoAdapter.ts b/server/src/engines/echo/EchoAdapter.ts index 1bcda57..c5ccc35 100644 --- a/server/src/engines/echo/EchoAdapter.ts +++ b/server/src/engines/echo/EchoAdapter.ts @@ -1,11 +1,13 @@ import { randomUUID } from 'node:crypto'; import type { EngineDescriptor, NormalizedEvent } from '@hub/shared'; +import { permissionModeDisposition } from '../../approvals/permissionMode.js'; import type { EngineAdapter, EngineRun, StartOptions } from '../types.js'; /** * In-process stub engine. Proves the adapter contract and powers keyless - * end-to-end tests: every user message triggers a tool call (which flows - * through the approval broker like a real one), a tool result, a streamed + * end-to-end tests: every user message triggers a tool call, then (depending on + * the session's permission mode) either an approval request the hub relays to + * the user or a silent auto-allow/deny, followed by a tool result, a streamed * markdown message, and a done event. */ export class EchoAdapter implements EngineAdapter { @@ -72,13 +74,21 @@ class EchoRun implements EngineRun { } const callId = `echo-call-${randomUUID()}`; const requestId = `echo-req-${randomUUID()}`; + // Emulate a real engine applying its own permission mode: only surface an + // approval when the mode wants the user asked. auto/bypass proceed silently; + // dontAsk auto-denies. The hub never decides policy. + const disposition = permissionModeDisposition(this.opts.permissionMode); this.later(10, () => { this.emit({ type: 'tool_call', callId, toolName: 'echo_probe', input: { text } }); - // Ask for approval exactly like a real engine would; the runtime's - // broker decides whether the user actually sees it. - this.pendingApproval = { requestId, text }; - this.approvalCallId.set(requestId, callId); - this.emit({ type: 'approval_request', requestId, toolName: 'echo_probe', input: { text } }); + if (disposition === 'ask') { + this.pendingApproval = { requestId, text }; + this.approvalCallId.set(requestId, callId); + this.emit({ type: 'approval_request', requestId, toolName: 'echo_probe', input: { text } }); + } else if (disposition === 'deny') { + this.finishTurn(callId, text, 'deny', 'Denied by permission mode'); + } else { + this.finishTurn(callId, text, 'allow'); + } }); } @@ -90,13 +100,22 @@ class EchoRun implements EngineRun { this.pendingApproval = null; const callId = this.approvalCallId.get(requestId) ?? 'unknown'; this.approvalCallId.delete(requestId); + this.finishTurn(callId, text, behavior, opts?.message); + } + /** Emit the tool result + assistant reply + done for an allowed or denied tool. */ + private finishTurn( + callId: string, + text: string, + behavior: 'allow' | 'deny', + message?: string, + ): void { if (behavior === 'deny') { this.later(10, () => { this.emit({ type: 'tool_result', callId, - output: `Denied by user${opts?.message ? `: ${opts.message}` : ''}`, + output: `Denied${message ? `: ${message}` : ''}`, isError: true, }); this.emit({ diff --git a/server/src/engines/types.ts b/server/src/engines/types.ts index 0db0d0b..9a1f90d 100644 --- a/server/src/engines/types.ts +++ b/server/src/engines/types.ts @@ -1,11 +1,16 @@ -import type { ApprovalMode, EngineDescriptor, NormalizedEvent } from '@hub/shared'; +import type { EngineDescriptor, NormalizedEvent, PermissionMode } from '@hub/shared'; export interface StartOptions { /** Platform session id (not the engine-native one). */ sessionId: string; /** Validated absolute working directory. */ cwd: string; - approvalMode: ApprovalMode; + /** Passed to the CLI as --permission-mode; null/undefined = inherit default. */ + permissionMode?: PermissionMode | null; + /** Passed to the CLI as --model; null/undefined = inherit default. */ + model?: string | null; + /** Passed to the CLI as --settings (file path or inline JSON); null = inherit. */ + settings?: string | null; /** Engine-native session id to resume, if any. */ resumeEngineSessionId?: string; env?: Record; @@ -15,9 +20,10 @@ export interface StartOptions { * A live engine run bound to one session. Implementations own the child * process (if any) and translate its native stream into NormalizedEvents. * - * Approval contract: the adapter emits `approval_request` events and then - * blocks the tool until respondToApproval() is called — it does NOT decide - * policy. Policy lives in the ApprovalBroker. + * Approval contract: the adapter emits `approval_request` events only for tools + * its permission mode wants asked, then blocks the tool until respondToApproval() + * is called. The hub relays every surfaced request to the user; auto-allow/deny + * is decided by the engine's permission mode, not by the hub. */ export interface EngineRun { readonly pid?: number; diff --git a/server/src/sessions/SessionManager.ts b/server/src/sessions/SessionManager.ts index e0fbfeb..2074e8d 100644 --- a/server/src/sessions/SessionManager.ts +++ b/server/src/sessions/SessionManager.ts @@ -6,6 +6,7 @@ import type { HubBus } from '../bus.js'; import type { HubConfig } from '../config.js'; import type { EventsRepo, SessionsRepo } from '../db/repos.js'; import type { EngineRegistry } from '../engines/registry.js'; +import { presetToPermissionMode } from '../approvals/permissionMode.js'; import { validateCwd } from '../fs/workspace.js'; import { HubError, SessionRuntime } from './SessionRuntime.js'; @@ -40,6 +41,8 @@ export class SessionManager { } } this.sessions.setPid(s.id, null); + // The process is gone but the session stays resumable. + this.sessions.setEngineState(s.id, 'hibernated'); } for (const s of this.sessions.list(true)) { for (const { requestId } of this.events.unresolvedApprovals(s.id)) { @@ -71,12 +74,18 @@ export class SessionManager { if (!cwdCheck.ok || !cwdCheck.resolved) { throw new HubError('invalid_cwd', cwdCheck.error ?? 'Invalid working directory'); } + // permissionMode is canonical; the legacy approvalMode preset is a fallback. + // Unset => null => the CLI/`~/.claude` default (which is `manual`). + const permissionMode = + req.permissionMode ?? (req.approvalMode ? presetToPermissionMode(req.approvalMode) : null); const dto = this.sessions.create({ id: randomUUID(), title: req.title?.trim() || `${path.basename(cwdCheck.resolved)} · ${engine.name}`, engineId: req.engineId, cwd: cwdCheck.resolved, - approvalMode: req.approvalMode ?? 'approve_all', + permissionMode, + model: req.model ?? null, + settings: req.settings ?? null, }); this.bus.emit('session_upsert', dto); return dto; @@ -94,7 +103,11 @@ export class SessionManager { const existing = this.sessions.get(id); if (!existing) throw new HubError('not_found', 'Session not found'); if (req.title !== undefined) this.sessions.setTitle(id, req.title.trim() || existing.title); - if (req.approvalMode !== undefined) this.sessions.setApprovalMode(id, req.approvalMode); + if (req.permissionMode !== undefined) this.sessions.setPermissionMode(id, req.permissionMode); + else if (req.approvalMode !== undefined) + this.sessions.setPermissionMode(id, presetToPermissionMode(req.approvalMode)); + if (req.model !== undefined) this.sessions.setRequestedModel(id, req.model); + if (req.settings !== undefined) this.sessions.setSettings(id, req.settings); if (req.archived !== undefined) this.sessions.setArchived(id, req.archived); this.runtimes.get(id)?.refresh(); const dto = this.sessions.get(id)!; diff --git a/server/src/sessions/SessionRuntime.ts b/server/src/sessions/SessionRuntime.ts index e0d9ea2..3f3ab34 100644 --- a/server/src/sessions/SessionRuntime.ts +++ b/server/src/sessions/SessionRuntime.ts @@ -1,20 +1,21 @@ import { randomUUID } from 'node:crypto'; -import type { NormalizedEvent, SessionDto, SessionStatus } from '@hub/shared'; +import type { EngineState, NormalizedEvent, SessionDto, SessionStatus } from '@hub/shared'; import type { HubBus } from '../bus.js'; import type { HubConfig } from '../config.js'; import type { EventsRepo, SessionsRepo } from '../db/repos.js'; -import { decideApproval } from '../approvals/ApprovalBroker.js'; import type { EngineAdapter, EngineRun } from '../engines/types.js'; /** * Owns the live engine run for one session: lazily starts the engine on the - * first message (resuming the engine-native session where available), routes - * approvals through policy, persists every complete event, and fans out to - * the WebSocket layer via the bus. + * first message (resuming the engine-native session where available), relays + * engine-surfaced approvals to the user, persists every complete event, and + * fans out to the WebSocket layer via the bus. * * State machine: idle -> running <-> awaiting_approval -> idle | error. - * Only one in-flight turn per session. + * Only one in-flight turn per session; extra messages queue (up to MAX_QUEUE). */ +const MAX_QUEUE = 100; + export class SessionRuntime { private run: EngineRun | null = null; private busy = false; @@ -22,6 +23,11 @@ export class SessionRuntime { private approvalTimers = new Map>(); /** Approvals waiting on the user (requestIds). */ private pendingUserApprovals = new Set(); + /** True while we are intentionally tearing the engine down (idle/stop/delete), + * so its exit reads as `hibernated` rather than a crash (`dead`). */ + private disposing = false; + /** Messages received while busy, drained one turn at a time on `done`. */ + private queue: string[] = []; constructor( private session: SessionDto, @@ -46,6 +52,11 @@ export class SessionRuntime { this.bus.emit('status', this.session.id, status); } + private setEngineState(state: EngineState): void { + this.sessions.setEngineState(this.session.id, state); + this.emitUpsert(); + } + private persist(event: NormalizedEvent): void { const row = this.events.append(this.session.id, event); this.bus.emit('event', row); @@ -58,17 +69,38 @@ export class SessionRuntime { const run = this.adapter.start({ sessionId: this.session.id, cwd: this.session.cwd, - approvalMode: this.session.approvalMode, + permissionMode: this.session.permissionMode, + model: this.session.requestedModel, + settings: this.session.settings, resumeEngineSessionId: this.session.engineSessionId ?? undefined, }); this.run = run; if (run.pid) this.sessions.setPid(this.session.id, run.pid); + this.setEngineState('live'); run.onEvent((e) => this.handleEngineEvent(e)); run.onDelta((messageKey, delta) => this.bus.emit('delta', this.session.id, messageKey, delta)); run.onExit(() => { this.sessions.setPid(this.session.id, null); - if (this.run === run) this.run = null; + const wasCurrent = this.run === run; + if (wasCurrent) this.run = null; + if (this.disposing) { + // An intentional teardown (idle/stop/delete) already set the state. + this.disposing = false; + return; + } + if (wasCurrent) { + // Unexpected exit while still the live run = a crash. If a turn is still + // open (busy => the adapter didn't emit a terminal 'done', e.g. SIGKILL), + // close it so the session doesn't hang in 'running'. + if (this.busy) { + this.busy = false; + this.clearApprovalState(); + this.persist({ type: 'done', reason: 'error', errorMessage: 'engine process exited unexpectedly' }); + this.setStatus('error'); + } + this.setEngineState('dead'); + } }); return run; } @@ -88,26 +120,17 @@ export class SessionRuntime { return; } case 'approval_request': { - const decision = decideApproval(this.session.approvalMode, e.toolName); + // The engine only surfaces tools its permission mode wants asked, so the + // hub always relays the request to the user (no hub-side auto-allow). this.persist(e); - if (decision === 'allow') { - this.persist({ - type: 'approval_resolved', - requestId: e.requestId, - behavior: 'allow', - resolvedBy: 'policy', - }); - this.run?.respondToApproval(e.requestId, 'allow'); - } else { - this.pendingUserApprovals.add(e.requestId); - this.setStatus('awaiting_approval'); - if (this.config.approvalTimeoutMs > 0) { - const t = setTimeout( - () => this.respondToApproval(e.requestId, 'deny', 'Timed out waiting for approval'), - this.config.approvalTimeoutMs, - ); - this.approvalTimers.set(e.requestId, t); - } + this.pendingUserApprovals.add(e.requestId); + this.setStatus('awaiting_approval'); + if (this.config.approvalTimeoutMs > 0) { + const t = setTimeout( + () => this.respondToApproval(e.requestId, 'deny', 'Timed out waiting for approval'), + this.config.approvalTimeoutMs, + ); + this.approvalTimers.set(e.requestId, t); } return; } @@ -120,7 +143,17 @@ export class SessionRuntime { this.persist(e); this.setStatus(e.reason === 'error' ? 'error' : 'idle'); this.emitUpsert(); - this.armIdleTimer(); + // Drain the next queued message into a fresh turn, if any. + const next = this.queue.shift(); + if (next !== undefined) { + try { + this.startTurn(next); + } catch { + // startTurn already set the error status; nothing more to do. + } + } else { + this.armIdleTimer(); + } return; } default: @@ -148,17 +181,51 @@ export class SessionRuntime { this.idleTimer = setTimeout(() => { if (!this.busy && this.run) { // Engine process is torn down; the session stays resumable. + this.disposing = true; this.run.dispose(); this.run = null; + this.sessions.setPid(this.session.id, null); + this.setEngineState('hibernated'); } }, this.config.engineIdleTimeoutMs); this.idleTimer.unref?.(); } + /** + * Explicitly tear down the live engine process now (e.g. a user "stop"), + * leaving the session resumable. If a turn is in flight it is ended. + */ + stopEngine(): void { + if (!this.run) return; + this.disposing = true; + this.queue.length = 0; + if (this.idleTimer) clearTimeout(this.idleTimer); + if (this.busy) { + this.busy = false; + this.clearApprovalState(); + this.persist({ type: 'done', reason: 'killed' }); + this.setStatus('idle'); + } + this.run.dispose(); + this.run = null; + this.sessions.setPid(this.session.id, null); + this.setEngineState('hibernated'); + } + sendUserMessage(text: string): void { + // A message sent while a turn is in flight is queued and drained on `done`, + // rather than rejected. Bounded so a runaway client can't grow it forever. if (this.busy) { - throw new HubError('session_busy', 'A turn is already in progress; interrupt it first.'); + if (this.queue.length >= MAX_QUEUE) { + throw new HubError('queue_full', `Too many messages queued (max ${MAX_QUEUE}).`); + } + this.queue.push(text); + return; } + this.startTurn(text); + } + + private startTurn(text: string): void { if (this.idleTimer) clearTimeout(this.idleTimer); this.busy = true; this.persist({ type: 'message', role: 'user', text, messageKey: `user-${randomUUID()}` }); @@ -188,11 +255,13 @@ export class SessionRuntime { } async interrupt(): Promise { + // An interrupt is a deliberate stop; drop anything queued behind this turn. + this.queue.length = 0; if (!this.run || !this.busy) return; await this.run.interrupt(); } - /** Refresh cached session row (e.g. after approval-mode change). */ + /** Refresh cached session row (e.g. after permission-mode change). */ refresh(): void { const dto = this.sessions.get(this.session.id); if (dto) this.session = dto; @@ -201,6 +270,8 @@ export class SessionRuntime { dispose(): void { if (this.idleTimer) clearTimeout(this.idleTimer); this.clearApprovalState(); + this.queue.length = 0; + this.disposing = true; this.run?.dispose(); this.run = null; } diff --git a/server/test/adapter-contract.ts b/server/test/adapter-contract.ts index aaf37ca..3f63e7e 100644 --- a/server/test/adapter-contract.ts +++ b/server/test/adapter-contract.ts @@ -17,13 +17,13 @@ export function adapterContract(makeAdapter: () => EngineAdapter, opts: { cwd: s const run = makeAdapter().start({ sessionId: 's1', cwd: opts.cwd, - approvalMode: 'full_auto', + permissionMode: 'manual', }); const events: NormalizedEvent[] = []; const done = new Promise((resolve) => { run.onEvent((e) => { events.push(e); - // Contract consumers (SessionRuntime) auto-allow via policy; emulate. + // The hub relays surfaced approvals to the user; emulate the user allowing. if (e.type === 'approval_request') run.respondToApproval(e.requestId, 'allow'); if (e.type === 'done') resolve(); }); @@ -53,7 +53,7 @@ export function adapterContract(makeAdapter: () => EngineAdapter, opts: { cwd: s const run = makeAdapter().start({ sessionId: 's2', cwd: opts.cwd, - approvalMode: 'approve_all', + permissionMode: 'manual', }); const events: NormalizedEvent[] = []; const done = new Promise((resolve) => { @@ -77,7 +77,7 @@ export function adapterContract(makeAdapter: () => EngineAdapter, opts: { cwd: s const run = makeAdapter().start({ sessionId: 's3', cwd: opts.cwd, - approvalMode: 'approve_all', + permissionMode: 'manual', }); const events: NormalizedEvent[] = []; const done = new Promise((resolve) => { diff --git a/server/test/broker.test.ts b/server/test/broker.test.ts deleted file mode 100644 index bbd6a0c..0000000 --- a/server/test/broker.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { decideApproval } from '../src/approvals/ApprovalBroker.js'; - -describe('decideApproval', () => { - it('approve_all asks for everything', () => { - expect(decideApproval('approve_all', 'Read')).toBe('ask_user'); - expect(decideApproval('approve_all', 'Bash')).toBe('ask_user'); - }); - - it('approve_writes auto-allows read-only tools, asks for the rest', () => { - expect(decideApproval('approve_writes', 'Read')).toBe('allow'); - expect(decideApproval('approve_writes', 'Grep')).toBe('allow'); - expect(decideApproval('approve_writes', 'Bash')).toBe('ask_user'); - expect(decideApproval('approve_writes', 'Write')).toBe('ask_user'); - expect(decideApproval('approve_writes', 'Edit')).toBe('ask_user'); - expect(decideApproval('approve_writes', 'mcp__github__merge_pull_request')).toBe('ask_user'); - }); - - it('full_auto allows everything', () => { - expect(decideApproval('full_auto', 'Bash')).toBe('allow'); - expect(decideApproval('full_auto', 'Write')).toBe('allow'); - }); -}); diff --git a/server/test/claude-live.test.ts b/server/test/claude-live.test.ts index 9bc6c73..5b59736 100644 --- a/server/test/claude-live.test.ts +++ b/server/test/claude-live.test.ts @@ -30,7 +30,7 @@ live('ClaudeCodeAdapter (live CLI)', () => { }); it('write-file turn with approval round trip; resume recalls context', async () => { - const run = adapter.start({ sessionId: 'live-1', cwd: workspace, approvalMode: 'approve_all' }); + const run = adapter.start({ sessionId: 'live-1', cwd: workspace, permissionMode: 'manual' }); const events: NormalizedEvent[] = []; let engineSessionId: string | undefined; @@ -60,7 +60,7 @@ live('ClaudeCodeAdapter (live CLI)', () => { const run2 = adapter.start({ sessionId: 'live-1', cwd: workspace, - approvalMode: 'approve_all', + permissionMode: 'manual', resumeEngineSessionId: engineSessionId, }); const texts: string[] = []; diff --git a/server/test/e2e.test.ts b/server/test/e2e.test.ts index 8f0ae74..0d7ec13 100644 --- a/server/test/e2e.test.ts +++ b/server/test/e2e.test.ts @@ -249,7 +249,7 @@ describe('end-to-end with echo engine', () => { await server.close(); }, 30_000); - it('full_auto mode never surfaces an approval to the user', async () => { + it('full_auto (bypassPermissions) runs tools without surfacing any approval', async () => { server = await buildServer(testConfig(dataDir, workspace)); const port = server.address.port; const c = new Client(port); @@ -257,11 +257,13 @@ describe('end-to-end with echo engine', () => { c.send({ type: 'auth', token: TOKEN }); await c.next((m) => m.type === 'auth_ok'); + // Legacy preset alias: full_auto maps to the CLI's bypassPermissions. const session = await api(port, 'POST', '/api/sessions', { engineId: 'echo', cwd: workspace, approvalMode: 'full_auto', }); + expect(session.permissionMode).toBe('bypassPermissions'); c.send({ type: 'subscribe', sessionId: session.id }); c.send({ type: 'user_message', sessionId: session.id, text: 'yolo' }); await c.next( @@ -269,14 +271,118 @@ describe('end-to-end with echo engine', () => { 10_000, ); - // No awaiting_approval status ever, and the approval resolved by policy. + // The engine applies the mode itself, so nothing is asked: no awaiting_approval + // status and no approval events at all, yet the tool still ran. expect( c.received.some((m) => m.type === 'status' && m.status === 'awaiting_approval'), ).toBe(false); - const resolved = c - .events(session.id) - .find((e) => e.event.type === 'approval_resolved'); - expect((resolved!.event as { resolvedBy: string }).resolvedBy).toBe('policy'); + const types = c.events(session.id).map((e) => e.event.type); + expect(types).not.toContain('approval_request'); + expect(types).not.toContain('approval_resolved'); + expect(types).toContain('tool_call'); + expect(types).toContain('tool_result'); + expect(types[types.length - 1]).toBe('done'); + + c.close(); + await server.close(); + }, 15_000); + + it('surfaces engine liveness and stop_engine hibernates a resumable process', async () => { + server = await buildServer(testConfig(dataDir, workspace)); + const port = server.address.port; + const c = new Client(port); + await c.open(); + c.send({ type: 'auth', token: TOKEN }); + await c.next((m) => m.type === 'auth_ok'); + + // A brand-new session has not spawned an engine yet. + const session = await api(port, 'POST', '/api/sessions', { + engineId: 'echo', + cwd: workspace, + permissionMode: 'bypassPermissions', + }); + expect(session.engineState).toBe('new'); + + // First turn spawns the engine -> live (idle timeout is 0 here, so it stays). + c.send({ type: 'subscribe', sessionId: session.id }); + c.send({ type: 'user_message', sessionId: session.id, text: 'wake up' }); + const firstDone = await c.next( + (m) => m.type === 'event' && m.sessionId === session.id && m.event.type === 'done', + 10_000, + ); + const firstDoneId = (firstDone as Extract).eventId; + const live = await api(port, 'GET', `/api/sessions/${session.id}`); + expect(live.engineState).toBe('live'); + + // Explicit stop tears the process down but keeps the session resumable. + c.send({ type: 'stop_engine', sessionId: session.id }); + await c.next( + (m) => m.type === 'session_upsert' && m.session.id === session.id && m.session.engineState === 'hibernated', + ); + const hibernated = await api(port, 'GET', `/api/sessions/${session.id}`); + expect(hibernated.engineState).toBe('hibernated'); + expect(hibernated.enginePid).toBeNull(); + + // A new message respawns it; wait for the NEW turn's done (by event id), then verify live. + c.send({ type: 'user_message', sessionId: session.id, text: 'and again' }); + await c.next( + (m) => + m.type === 'event' && + m.sessionId === session.id && + m.event.type === 'done' && + m.eventId > firstDoneId, + 10_000, + ); + const relive = await api(port, 'GET', `/api/sessions/${session.id}`); + expect(relive.engineState).toBe('live'); + + c.close(); + await server.close(); + }, 15_000); + + it('queues a message sent while busy and runs it after the current turn', async () => { + server = await buildServer(testConfig(dataDir, workspace)); + const port = server.address.port; + const c = new Client(port); + await c.open(); + c.send({ type: 'auth', token: TOKEN }); + await c.next((m) => m.type === 'auth_ok'); + + const session = await api(port, 'POST', '/api/sessions', { + engineId: 'echo', + cwd: workspace, + permissionMode: 'bypassPermissions', + }); + c.send({ type: 'subscribe', sessionId: session.id }); + // Two messages back to back: the second is queued while the first turn runs. + c.send({ type: 'user_message', sessionId: session.id, text: 'first' }); + c.send({ type: 'user_message', sessionId: session.id, text: 'second' }); + + const d1 = await c.next( + (m) => m.type === 'event' && m.sessionId === session.id && m.event.type === 'done', + ); + const d1id = (d1 as Extract).eventId; + await c.next( + (m) => + m.type === 'event' && m.sessionId === session.id && m.event.type === 'done' && m.eventId > d1id, + 10_000, + ); + + // No busy rejection; both turns ran, in order, with the queued one after the first done. + expect( + c.received.some((m) => m.type === 'error' && (m as { code: string }).code === 'session_busy'), + ).toBe(false); + const evs = c.events(session.id); + const userTexts = evs + .filter((e) => e.event.type === 'message' && (e.event as { role: string }).role === 'user') + .map((e) => (e.event as { text: string }).text); + expect(userTexts).toEqual(['first', 'second']); + expect(evs.filter((e) => e.event.type === 'done')).toHaveLength(2); + const secondUserIdx = evs.findIndex( + (e) => e.event.type === 'message' && (e.event as { text: string }).text === 'second', + ); + const firstDoneIdx = evs.findIndex((e) => e.event.type === 'done'); + expect(secondUserIdx).toBeGreaterThan(firstDoneIdx); c.close(); await server.close(); diff --git a/server/test/permissionMode.test.ts b/server/test/permissionMode.test.ts new file mode 100644 index 0000000..1439304 --- /dev/null +++ b/server/test/permissionMode.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { + permissionModeDisposition, + presetToPermissionMode, +} from '../src/approvals/permissionMode.js'; + +describe('presetToPermissionMode', () => { + it('maps the legacy 3-way dial onto CLI permission modes', () => { + expect(presetToPermissionMode('approve_all')).toBe('manual'); + expect(presetToPermissionMode('approve_writes')).toBe('acceptEdits'); + expect(presetToPermissionMode('full_auto')).toBe('bypassPermissions'); + }); +}); + +describe('permissionModeDisposition', () => { + it('asks for modes that prompt (and the inherited default)', () => { + expect(permissionModeDisposition('manual')).toBe('ask'); + expect(permissionModeDisposition('acceptEdits')).toBe('ask'); // non-edit tool still asks + expect(permissionModeDisposition('plan')).toBe('ask'); + expect(permissionModeDisposition(null)).toBe('ask'); + expect(permissionModeDisposition(undefined)).toBe('ask'); + }); + + it('allows for auto-approving modes', () => { + expect(permissionModeDisposition('auto')).toBe('allow'); + expect(permissionModeDisposition('bypassPermissions')).toBe('allow'); + }); + + it('denies for dontAsk (auto-deny unless allowlisted)', () => { + expect(permissionModeDisposition('dontAsk')).toBe('deny'); + }); +}); diff --git a/shared/src/api.ts b/shared/src/api.ts index 082f8ba..cb901a0 100644 --- a/shared/src/api.ts +++ b/shared/src/api.ts @@ -1,4 +1,4 @@ -import type { ApprovalMode, SessionStatus } from './events.js'; +import type { ApprovalMode, EngineState, PermissionMode, SessionStatus } from './events.js'; export interface EngineDescriptor { id: string; @@ -18,25 +18,47 @@ export interface SessionDto { engineId: string; engineSessionId: string | null; cwd: string; - approvalMode: ApprovalMode; + /** Canonical permission control, passed to the CLI as --permission-mode. + * null = inherit the CLI / ~/.claude default. */ + permissionMode: PermissionMode | null; + /** Interaction state of the current turn. */ status: SessionStatus; + /** Engine-process liveness, independent of `status`. */ + engineState: EngineState; + /** OS pid of the live engine process, or null when not running. */ + enginePid: number | null; createdAt: number; updatedAt: number; archivedAt: number | null; lastCostUsd: number | null; + /** Model the engine actually reported using (from engine_meta). */ model: string | null; + /** User-requested model override, passed to the CLI as --model (null = inherit). */ + requestedModel: string | null; + /** Optional --settings override (file path or inline JSON); null = inherit. */ + settings: string | null; } export interface CreateSessionRequest { title?: string; engineId: string; cwd: string; + /** Canonical control. When omitted, `approvalMode` (legacy preset) is used. */ + permissionMode?: PermissionMode; + /** Legacy preset alias; mapped to permissionMode when permissionMode is unset. */ approvalMode?: ApprovalMode; + model?: string; + settings?: string; } export interface UpdateSessionRequest { title?: string; + /** null resets to inherit the ~/.claude default. */ + permissionMode?: PermissionMode | null; + /** Legacy preset alias; mapped to permissionMode. */ approvalMode?: ApprovalMode; + model?: string; + settings?: string; archived?: boolean; } diff --git a/shared/src/events.ts b/shared/src/events.ts index 974c461..efff672 100644 --- a/shared/src/events.ts +++ b/shared/src/events.ts @@ -1,6 +1,24 @@ -/** Per-session approval policy: the "safety dial". */ +/** + * Legacy UI presets for the "safety dial". Kept as friendly aliases that map + * onto the CLI's native PermissionMode (see presetToPermissionMode); the + * canonical per-session control is `permissionMode`. + */ export type ApprovalMode = 'approve_all' | 'approve_writes' | 'full_auto'; +/** + * The Claude CLI's own `--permission-mode` values (2.1.x). This is the canonical + * per-session permission control: it passes straight through to the CLI, so a + * hub session behaves exactly like a terminal session started with that flag. + * `null`/unset means "inherit the CLI/`~/.claude` default" (which is `manual`). + */ +export type PermissionMode = + | 'manual' // = the CLI's `default`: ask for anything not pre-allowed + | 'acceptEdits' // auto-allow edits + safe fs commands, ask for the rest + | 'plan' // read-only exploration, edits blocked, still prompts + | 'auto' // auto-approve with background safety checks (research preview) + | 'dontAsk' // auto-deny unless pre-allowed by settings rules + | 'bypassPermissions'; // skip prompts (except explicit ask rules / circuit breakers) + export type SessionStatus = | 'idle' | 'starting' @@ -8,6 +26,17 @@ export type SessionStatus = | 'awaiting_approval' | 'error'; +/** + * Engine-process liveness, independent of the interaction `status`. A session + * is a terminal window that keeps its scrollback: the process can be gone while + * the session stays resumable. + * - new: never started (spawns lazily on the first message) + * - live: the engine child process is running and attached + * - hibernated: torn down to save resources (idle timeout or stop); resumable + * - dead: exited unexpectedly (crash); still resumable on the next message + */ +export type EngineState = 'new' | 'live' | 'hibernated' | 'dead'; + export interface UsageSummary { inputTokens?: number; outputTokens?: number; diff --git a/shared/src/protocol.ts b/shared/src/protocol.ts index db807cf..6f59a6f 100644 --- a/shared/src/protocol.ts +++ b/shared/src/protocol.ts @@ -19,7 +19,9 @@ export type ClientMsg = behavior: 'allow' | 'deny'; message?: string; } - | { type: 'interrupt'; sessionId: string }; + | { type: 'interrupt'; sessionId: string } + /** Tear down the live engine process now; the session stays resumable. */ + | { type: 'stop_engine'; sessionId: string }; export type ServerMsg = | { type: 'auth_ok' } diff --git a/web/src/components/ChatView.svelte b/web/src/components/ChatView.svelte index 6a0a460..57072c8 100644 --- a/web/src/components/ChatView.svelte +++ b/web/src/components/ChatView.svelte @@ -49,7 +49,8 @@ function send(): void { const text = composer.trim(); - if (!text || busy) return; + if (!text) return; + // While a turn is running the server queues this for the next turn. composer = ''; sendUserMessage(sessionId, text); } @@ -97,16 +98,19 @@
{#if busy} {/if} - +
diff --git a/web/src/components/NewSessionDialog.svelte b/web/src/components/NewSessionDialog.svelte index b80a03f..ff27548 100644 --- a/web/src/components/NewSessionDialog.svelte +++ b/web/src/components/NewSessionDialog.svelte @@ -1,8 +1,13 @@ -
+
{status.replace('_', ' ')} + + {engineStateLabel(session.engineState)} + {session.cwd}
+ {#if session.engineState === 'live'} + + {/if} {#if session.model}{session.model}{/if} {#if formatCost(session.lastCostUsd)} {formatCost(session.lastCostUsd)} {/if} - + {#each PERMISSION_MODES as mode (mode.value ?? 'inherit')} + {/each}
@@ -73,6 +93,34 @@ .status[data-status='error'] { color: var(--red); } + .engine-state { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); + } + .dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-dim); + } + .engine-state[data-engine='live'] .dot { + background: var(--green); + } + .engine-state[data-engine='dead'] .dot { + background: var(--red); + } + .engine-state[data-engine='dead'] { + color: var(--red); + } + .stop { + padding: 3px 8px; + font-size: 11px; + } .cwd { font-family: var(--mono); color: var(--text-dim); diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index f6c723d..ab8264c 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -30,9 +30,35 @@ export function summarizeInput(toolName: string, input: unknown): string { return s.length > 80 ? s.slice(0, 77) + '…' : s; } -/** Approval modes in dial order with display labels. */ -export const APPROVAL_MODES = [ - { value: 'approve_all', label: 'Approve every tool', short: 'approve all' }, - { value: 'approve_writes', label: 'Approve writes only', short: 'writes only' }, - { value: 'full_auto', label: 'Full auto (YOLO)', short: 'FULL AUTO' }, -] as const; +import type { EngineState, PermissionMode } from '@hub/shared'; + +/** Short human label for the engine-process liveness dot. */ +export function engineStateLabel(state: EngineState): string { + return { new: 'not started', live: 'live', hibernated: 'asleep', dead: 'crashed' }[state]; +} + +/** + * Permission modes in dial order with display labels. These are the Claude CLI's + * own `--permission-mode` values; `value: null` means inherit the directory's + * ~/.claude default. `danger` flags the modes that reduce prompting. + */ +export const PERMISSION_MODES: { + value: PermissionMode | null; + label: string; + short: string; + hint: string; + danger?: boolean; +}[] = [ + { value: null, label: 'Inherit (~/.claude)', short: 'inherit', hint: "Use the directory's own Claude settings (CLI default)" }, + { value: 'manual', label: 'Ask for every tool', short: 'ask all', hint: 'Prompt before any tool not pre-approved' }, + { value: 'acceptEdits', label: 'Auto-accept edits', short: 'edits ok', hint: 'Allow edits + safe fs commands, ask for the rest' }, + { value: 'plan', label: 'Plan (read-only)', short: 'plan', hint: 'Explore and read only; edits blocked' }, + { value: 'dontAsk', label: 'Deny unless allowlisted', short: 'deny', hint: 'Auto-deny tools not pre-approved in settings' }, + { value: 'auto', label: 'Auto (preview)', short: 'auto', hint: 'Auto-approve with background safety checks' }, + { value: 'bypassPermissions', label: 'Full auto (bypass)', short: 'BYPASS', hint: 'Skip prompts entirely', danger: true }, +]; + +/** The mode that gets the red "danger" treatment in the UI. */ +export function isDangerMode(mode: PermissionMode | null): boolean { + return mode === 'bypassPermissions'; +} diff --git a/web/src/lib/markdown.ts b/web/src/lib/markdown.ts index 8205dc5..592e46d 100644 --- a/web/src/lib/markdown.ts +++ b/web/src/lib/markdown.ts @@ -1,13 +1,59 @@ import { Marked } from 'marked'; import { markedHighlight } from 'marked-highlight'; -import hljs from 'highlight.js'; +// Core + a language subset instead of the full highlight.js bundle (~900 KB). +// registerLanguage also registers each module's aliases (ts, js, py, sh, yml, ...). +import hljs from 'highlight.js/lib/core'; +import bash from 'highlight.js/lib/languages/bash'; +import c from 'highlight.js/lib/languages/c'; +import cpp from 'highlight.js/lib/languages/cpp'; +import css from 'highlight.js/lib/languages/css'; +import diff from 'highlight.js/lib/languages/diff'; +import dockerfile from 'highlight.js/lib/languages/dockerfile'; +import go from 'highlight.js/lib/languages/go'; +import java from 'highlight.js/lib/languages/java'; +import javascript from 'highlight.js/lib/languages/javascript'; +import json from 'highlight.js/lib/languages/json'; +import markdown from 'highlight.js/lib/languages/markdown'; +import plaintext from 'highlight.js/lib/languages/plaintext'; +import python from 'highlight.js/lib/languages/python'; +import rust from 'highlight.js/lib/languages/rust'; +import shell from 'highlight.js/lib/languages/shell'; +import sql from 'highlight.js/lib/languages/sql'; +import typescript from 'highlight.js/lib/languages/typescript'; +import xml from 'highlight.js/lib/languages/xml'; +import yaml from 'highlight.js/lib/languages/yaml'; import DOMPurify from 'dompurify'; +const LANGUAGES = { + bash, + c, + cpp, + css, + diff, + dockerfile, + go, + java, + javascript, + json, + markdown, + plaintext, + python, + rust, + shell, + sql, + typescript, + xml, + yaml, +}; +for (const [name, lang] of Object.entries(LANGUAGES)) { + hljs.registerLanguage(name, lang); +} + const marked = new Marked( markedHighlight({ langPrefix: 'hljs language-', highlight(code, lang) { - const language = hljs.getLanguage(lang) ? lang : 'plaintext'; + const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext'; return hljs.highlight(code, { language }).value; }, }), diff --git a/web/src/lib/ws.ts b/web/src/lib/ws.ts index 258bd4d..cafb007 100644 --- a/web/src/lib/ws.ts +++ b/web/src/lib/ws.ts @@ -118,3 +118,7 @@ export function sendApproval( export function sendInterrupt(sessionId: string): void { send({ type: 'interrupt', sessionId }); } + +export function sendStopEngine(sessionId: string): void { + send({ type: 'stop_engine', sessionId }); +}