From b6f772f901abe0648eccdb6fd58156f6522e666b Mon Sep 17 00:00:00 2001 From: Adam Jeniski Date: Tue, 20 Jan 2026 15:31:41 -0500 Subject: [PATCH] add resizing --- CLAUDE.md | 226 +++++------ README.md | 212 +++------- client/CLAUDE.md | 103 +++++ client/package-lock.json | 25 ++ client/package.json | 1 + client/src/lib/CLAUDE.md | 115 ++++++ client/src/lib/api.ts | 39 +- client/src/lib/components/MessageList.svelte | 17 +- client/src/lib/components/TerminalView.svelte | 384 ++++++++++++++---- client/src/routes/CLAUDE.md | 49 +++ e2e/CLAUDE.md | 100 +++-- script/CLAUDE.md | 33 ++ script/dev | 66 ++- server/CLAUDE.md | 87 ++++ server/src/spiceflow/CLAUDE.md | 50 +++ server/src/spiceflow/adapters/CLAUDE.md | 71 ++++ server/src/spiceflow/adapters/tmux.clj | 81 +++- server/src/spiceflow/api/CLAUDE.md | 68 ++++ server/src/spiceflow/api/routes.clj | 81 +++- server/src/spiceflow/db/CLAUDE.md | 92 +++++ server/src/spiceflow/session/CLAUDE.md | 75 ++++ server/src/spiceflow/terminal/diff.clj | 172 ++++++++ 22 files changed, 1727 insertions(+), 420 deletions(-) create mode 100644 client/CLAUDE.md create mode 100644 client/src/lib/CLAUDE.md create mode 100644 client/src/routes/CLAUDE.md create mode 100644 script/CLAUDE.md create mode 100644 server/CLAUDE.md create mode 100644 server/src/spiceflow/CLAUDE.md create mode 100644 server/src/spiceflow/adapters/CLAUDE.md create mode 100644 server/src/spiceflow/api/CLAUDE.md create mode 100644 server/src/spiceflow/db/CLAUDE.md create mode 100644 server/src/spiceflow/session/CLAUDE.md create mode 100644 server/src/spiceflow/terminal/diff.clj diff --git a/CLAUDE.md b/CLAUDE.md index 0b82f5b..22c8a75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,167 +1,137 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Spiceflow is an AI Session Orchestration PWA for monitoring and interacting with Claude Code and OpenCode CLI sessions from mobile devices or web browsers. It's a monorepo with three main components: a Clojure backend server, a SvelteKit frontend, and Playwright E2E tests. - ## Commands -### Backend (Clojure) +```bash +./script/dev # Start backend + frontend +./script/test # Run E2E tests +``` + +### Backend ```bash cd server -clj -M:run # Start server (port 3000) -clj -M:test # Run tests with Kaocha -clj -M:repl # Start REPL with nREPL for interactive development +clj -M:dev # Start REPL with dev tools +clj -M:run # Start server (production mode) +clj -M:test # Run unit tests ``` -### Frontend (SvelteKit) +**REPL commands:** +```clojure +(go) ; Start server + file watcher +(reset) ; Reload code +(stop) ; Stop server +``` + +### Frontend ```bash cd client -npm install # Install dependencies -npm run dev # Start dev server (port 5173, proxies /api to 3000) -npm run build # Production build -npm run check # Type checking with svelte-check -npm run check:watch # Watch mode for type checking +npm install +npm run dev # Dev server +npm run build # Production build +npm run check # TypeScript check ``` -### E2E Tests (Playwright) +### E2E Tests ```bash cd e2e -npm test # Run all tests (starts both servers automatically) -npm run test:headed # Run tests with visible browser -npm run test:ui # Interactive Playwright UI mode +npm test +npm run test:headed # Visible browser +npm run test:ui # Interactive explorer ``` -E2E tests use a separate database (`server/test-e2e.db`). - -### Development Scripts - -```bash -./script/dev # Start backend + frontend concurrently -./script/test # Start servers and run E2E tests -``` - -The `dev` script starts both servers and waits for each to be ready before proceeding. The `test` script uses a separate test database and cleans up after tests complete. - ## Architecture +### Backend + ``` -Claude Code/OpenCode CLI ↔ Spiceflow Server (Clojure) ↔ PWA Client (SvelteKit) - ↓ - SQLite DB +server/src/spiceflow/ +├── core.clj # Entry point +├── config.clj # Settings from config.edn +├── db/ +│ ├── protocol.clj # DataStore interface +│ ├── sqlite.clj # SQLite implementation +│ └── memory.clj # In-memory (tests) +├── adapters/ +│ ├── protocol.clj # AgentAdapter interface +│ ├── claude.clj # Claude Code CLI +│ ├── opencode.clj # OpenCode CLI +│ └── tmux.clj # Terminal multiplexer +├── api/ +│ ├── routes.clj # HTTP endpoints +│ └── websocket.clj # Real-time streaming +└── session/ + └── manager.clj # Session lifecycle ``` -### Backend (`/server`) +### Frontend -- **Entry point**: `src/spiceflow/core.clj` - Ring/Jetty server with mount lifecycle -- **Routing**: `src/spiceflow/api/routes.clj` - Reitit-based REST API -- **Database**: Protocol-based abstraction (`db/protocol.clj`) with SQLite (`db/sqlite.clj`) and in-memory (`db/memory.clj`) implementations -- **Adapters**: Pluggable CLI integrations (`adapters/protocol.clj`) - Claude Code (`adapters/claude.clj`) and OpenCode (`adapters/opencode.clj`) -- **WebSocket**: `api/websocket.clj` - Real-time message streaming -- **Session management**: `session/manager.clj` - Session lifecycle +``` +client/src/ +├── routes/ # SvelteKit file-based routing +│ ├── +layout.svelte +│ ├── +page.svelte # Home (session list) +│ └── session/[id]/+page.svelte +├── lib/ +│ ├── api.ts # HTTP + WebSocket client +│ ├── stores/sessions.ts +│ └── components/ +└── app.css # Tailwind +``` -### Frontend (`/client`) +### Protocols -- **Routes**: SvelteKit file-based routing in `src/routes/` -- **State**: Svelte stores in `src/lib/stores/` (sessions, runtime selection) -- **API client**: `src/lib/api.ts` - HTTP and WebSocket clients -- **Components**: `src/lib/components/` - UI components - - `MessageList.svelte` - Displays messages with collapsible long content - - `PermissionRequest.svelte` - Permission prompts with accept/deny/steer actions - - `FileDiff.svelte` - Expandable file diffs for Write/Edit operations - - `SessionSettings.svelte` - Session settings dropdown (auto-accept edits) - - `InputBar.svelte` - Message input with steer mode support -- **PWA**: vite-plugin-pwa with Workbox service worker -- **Responsive**: Landscape mobile mode collapses header to hamburger menu +Database and CLI adapters use protocols for swappability: -### Key Protocols +```clojure +(defprotocol DataStore + (get-sessions [this]) + (save-message [this msg])) -**DataStore** (`db/protocol.clj`): -- `get-sessions`, `get-session`, `save-session`, `update-session`, `delete-session` -- `get-messages`, `save-message` - -**AgentAdapter** (`adapters/protocol.clj`): -- `discover` - Find existing CLI sessions -- `spawn` - Start CLI process with session -- `send` - Pipe message to stdin -- `read-stream` - Parse JSONL output -- Adding new runtimes requires implementing this protocol - -## Configuration - -Server configuration via `server/resources/config.edn` or environment variables: - -| Variable | Default | Description | -|----------|---------|-------------| -| `SPICEFLOW_PORT` | 3000 | Server port | -| `SPICEFLOW_HOST` | 0.0.0.0 | Server host | -| `SPICEFLOW_DB` | spiceflow.db | SQLite database path | -| `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions directory | -| `OPENCODE_CMD` | opencode | OpenCode command | - -## Features - -### Permission Handling -When Claude Code requests permission for file operations (Write/Edit) or shell commands (Bash), Spiceflow intercepts these and presents them to the user: -- **Accept**: Grant permission and continue -- **Deny**: Reject the request -- **Steer ("No, and...")**: Redirect Claude with alternative instructions - -File operations show expandable diffs displaying the exact changes being made. - -### Auto-Accept Edits -Claude sessions can enable "Auto-accept edits" in session settings (gear icon) to automatically grant file operation permissions. When enabled: - -- **Applies to**: `Write` and `Edit` tools only (file create/modify operations) -- **Does NOT apply to**: `Bash`, `WebFetch`, `WebSearch`, `NotebookEdit`, or other tools -- **Behavior**: Permission is still recorded in message history (green "accepted" status) but no user interaction required -- **Use case**: Reduces interruptions during coding sessions when you trust Claude to make file changes - -Other permission types (shell commands, web access, etc.) will still prompt for manual approval. - -### Session Management -- **Rename**: Click session title to rename -- **Delete**: Remove sessions from the session list -- **Condense**: Collapse long messages for easier scrolling - -### Mobile Optimization -- Landscape mode collapses the header to a hamburger menu -- Compact file diffs with minimal padding -- Touch-friendly permission buttons - -## Session Flow - -1. User opens PWA → sees list of tracked sessions -2. User selects session → loads message history -3. User types message → POST to `/api/sessions/:id/send` -4. Server spawns CLI process with `--resume` flag -5. Server pipes user message to stdin -6. CLI streams response via stdout (JSONL format) -7. Server broadcasts to client via WebSocket -8. **If permission required** → WebSocket sends permission request → User accepts/denies/steers -9. Process completes → response saved to database +(defprotocol AgentAdapter + (spawn-session [this session]) + (send-message [this session msg])) +``` ## API Endpoints | Endpoint | Method | Description | |----------|--------|-------------| | `/api/health` | GET | Health check | -| `/api/sessions` | GET | List all sessions | -| `/api/sessions` | POST | Create new session | +| `/api/sessions` | GET | List sessions | +| `/api/sessions` | POST | Create session | | `/api/sessions/:id` | GET | Get session with messages | -| `/api/sessions/:id` | PATCH | Update session (title, auto-accept-edits) | +| `/api/sessions/:id` | PATCH | Update session | | `/api/sessions/:id` | DELETE | Delete session | -| `/api/sessions/:id/send` | POST | Send message to session | -| `/api/sessions/:id/permission` | POST | Respond to permission request | -| `/ws` | WebSocket | Real-time message streaming | +| `/api/sessions/:id/send` | POST | Send message | +| `/api/sessions/:id/permission` | POST | Handle permission | +| `/api/sessions/:id/terminal` | GET | Get tmux content | +| `/api/sessions/:id/terminal/input` | POST | Send tmux input | +| `/api/ws` | WebSocket | Event streaming | -## Tech Stack +## Configuration -- **Backend**: Clojure 1.11, Ring/Jetty, Reitit, next.jdbc, SQLite, mount, Kaocha -- **Frontend**: SvelteKit 2.5, Svelte 4, TypeScript, Tailwind CSS, Vite, vite-plugin-pwa -- **E2E**: Playwright +Environment variables or `server/resources/config.edn`: + +| Variable | Default | Description | +|----------|---------|-------------| +| `SPICEFLOW_PORT` | 3000 | Server port | +| `SPICEFLOW_HOST` | 0.0.0.0 | Server host | +| `SPICEFLOW_DB` | spiceflow.db | SQLite path | +| `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions | +| `OPENCODE_CMD` | opencode | OpenCode binary | + +## Subdirectory CLAUDE.md Files + +Each directory has specific details: +- `server/` - REPL workflow, Mount lifecycle +- `server/src/spiceflow/db/` - Schema, queries +- `server/src/spiceflow/adapters/` - Adding adapters +- `server/src/spiceflow/api/` - HTTP handlers, WebSocket +- `server/src/spiceflow/session/` - Session state +- `client/` - SvelteKit, PWA +- `client/src/lib/` - API client, stores +- `client/src/routes/` - Pages, routing +- `e2e/` - E2E tests diff --git a/README.md b/README.md index 87f2ffc..28eaf07 100644 --- a/README.md +++ b/README.md @@ -1,190 +1,104 @@ # Spiceflow -AI Session Orchestration PWA for monitoring and interacting with Claude Code and OpenCode sessions. - -> "The spice must flow." - -## Architecture +A mobile-friendly web app for controlling AI coding assistants (Claude Code, OpenCode) remotely. ``` -┌─────────────────┐ ┌─────────────────────────┐ ┌─────────────────┐ -│ Claude Code │<--->│ Spiceflow Server │<--->│ PWA Client │ -│ (CLI) │ │ (Clojure) │ │ (Svelte) │ -└─────────────────┘ │ │ └─────────────────┘ - │ ┌─────────────────┐ │ -┌─────────────────┐ │ │ SQLite + DB │ │ -│ OpenCode │<--->│ │ Abstraction │ │ -│ (CLI) │ │ │ WebSocket/SSE │ │ -└─────────────────┘ │ └─────────────────┘ │ - └─────────────────────────┘ +Your Phone/Browser <-> Spiceflow Server <-> Claude Code CLI (on your computer) ``` ## Quick Start -### Prerequisites - -- Clojure CLI (deps.edn) -- Node.js 18+ and pnpm -- SQLite - -### Server - ```bash -cd server -clj -M:run +./script/dev # Start everything +# Backend: http://localhost:3000 +# Frontend: http://localhost:5173 ``` -The server will start on http://localhost:3000. +## Architecture -### Client - -```bash -cd client -pnpm install -pnpm dev +``` +┌─────────────────┐ HTTP/WS ┌──────────────────┐ stdin/stdout ┌─────────────────┐ +│ Your Browser │ <──────────────> │ Spiceflow │ <─────────────────> │ Claude Code │ +│ (SvelteKit) │ │ Server (Clojure)│ │ CLI │ +└─────────────────┘ └────────┬─────────┘ └─────────────────┘ + │ + v + ┌─────────────────┐ + │ SQLite DB │ + └─────────────────┘ ``` -The client dev server will start on http://localhost:5173 with proxy to the API. +## Project Structure -### Production Build - -```bash -# Build client -cd client -pnpm build - -# Run server (serves static files from client/build) -cd ../server -clj -M:run ``` +spiceflow/ +├── server/ # Clojure backend (API, database, CLI integration) +├── client/ # SvelteKit frontend (PWA, UI components) +└── e2e/ # Playwright tests +``` + +## Key Concepts + +| Concept | Description | +|---------|-------------| +| **Session** | A conversation with Claude Code. Has messages, settings, and working directory. | +| **Adapter** | Code that talks to a CLI (Claude, OpenCode, tmux). | +| **Permission** | When Claude wants to edit files or run commands, you must approve. | + +## How a Message Flows + +1. Browser sends POST to `/api/sessions/:id/send` +2. Server saves user message, starts CLI with `--resume` +3. Server sends message to CLI stdin +4. CLI outputs JSONL (one JSON per line) +5. Server parses JSONL, broadcasts via WebSocket +6. Browser shows streaming response +7. Server saves assistant message when done ## Features ### Permission Handling -When Claude Code requests permission for tools, Spiceflow intercepts and presents them for approval: - -- **Accept**: Grant permission and continue -- **Deny**: Reject the request -- **Steer ("No, and...")**: Redirect Claude with alternative instructions - -Supported tool types with human-readable descriptions: -- `Bash` - Shell commands (shows the command) -- `Write` - File creation (shows file path + diff preview) -- `Edit` - File modification (shows file path + diff preview) -- `WebFetch` - URL fetching (shows URL) -- `WebSearch` - Web searches (shows query) -- `NotebookEdit` - Jupyter notebook edits -- `Skill` - Slash command execution +When Claude wants to edit files or run commands: +- **Accept**: Allow the action +- **Deny**: Block it +- **Steer**: Say "No, but do this instead..." ### Auto-Accept Edits -Claude sessions can enable "Auto-accept edits" via the settings gear icon to automatically grant file operation permissions: +Enable in session settings to auto-approve `Write` and `Edit` tools only. Does not auto-approve `Bash`, `WebFetch`, etc. -- **Applies to**: `Write` and `Edit` tools only (file create/modify) -- **Does NOT apply to**: `Bash`, `WebFetch`, `WebSearch`, or other tools -- **Behavior**: Permission is recorded in message history (green "accepted" status) without user interaction -- **Use case**: Reduces interruptions during coding sessions when you trust Claude to make file changes +## Tech Stack -### Real-time Streaming - -Messages stream in real-time via WebSocket with: -- Content deltas as Claude types -- Permission request notifications -- Working directory updates -- Session status changes - -## API Endpoints - -| Method | Path | Description | -|--------|------|-------------| -| GET | `/api/health` | Health check | -| GET | `/api/sessions` | List all tracked sessions | -| POST | `/api/sessions` | Create/import a session | -| GET | `/api/sessions/:id` | Get session with messages | -| DELETE | `/api/sessions/:id` | Delete a session | -| POST | `/api/sessions/:id/send` | Send message to session | -| GET | `/api/discover/claude` | Discover Claude Code sessions | -| GET | `/api/discover/opencode` | Discover OpenCode sessions | -| POST | `/api/import` | Import a discovered session | -| WS | `/api/ws` | WebSocket for real-time updates | +| Layer | Technology | +|-------|------------| +| Backend | Clojure 1.11, Ring/Jetty, Reitit, SQLite | +| Frontend | SvelteKit 2.5, Svelte 4, TypeScript, Tailwind | +| Testing | Playwright, Kaocha | ## Development -### Running Tests - ```bash -# Server tests -cd server -clj -M:test +# Backend +cd server && clj -M:dev # REPL: (go), (reset), (stop) +clj -M:test # Unit tests -# Client type checking -cd client -pnpm check -``` +# Frontend +cd client && npm run dev # Dev server +npm run check # TypeScript -### Project Structure - -``` -spiceflow/ -├── server/ # Clojure backend -│ ├── deps.edn -│ ├── src/spiceflow/ -│ │ ├── core.clj # Entry point -│ │ ├── config.clj # Configuration -│ │ ├── db/ -│ │ │ ├── protocol.clj # DataStore protocol -│ │ │ ├── sqlite.clj # SQLite implementation -│ │ │ └── memory.clj # In-memory impl for tests -│ │ ├── adapters/ -│ │ │ ├── protocol.clj # AgentAdapter protocol -│ │ │ ├── claude.clj # Claude Code adapter -│ │ │ └── opencode.clj # OpenCode adapter -│ │ ├── api/ -│ │ │ ├── routes.clj # REST endpoints -│ │ │ └── websocket.clj # WebSocket handlers -│ │ └── session/ -│ │ └── manager.clj # Session lifecycle -│ └── test/spiceflow/ -│ -├── client/ # SvelteKit PWA -│ ├── src/ -│ │ ├── routes/ # SvelteKit routes -│ │ └── lib/ -│ │ ├── api.ts # API client -│ │ ├── stores/ # Svelte stores -│ │ └── components/ # UI components -│ └── static/ # PWA assets -│ -└── README.md +# E2E +cd e2e && npm test # Run tests +npm run test:ui # Interactive ``` ## Configuration -### Server - -Configuration via `resources/config.edn` or environment variables: +Environment variables or `server/resources/config.edn`: | Variable | Default | Description | |----------|---------|-------------| | `SPICEFLOW_PORT` | 3000 | Server port | | `SPICEFLOW_HOST` | 0.0.0.0 | Server host | -| `SPICEFLOW_DB` | spiceflow.db | SQLite database path | -| `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions directory | -| `OPENCODE_CMD` | opencode | OpenCode command | - -### PWA Icons - -Generate PWA icons from the SVG favicon: - -```bash -cd client/static -# Use a tool like svg2png or imagemagick to generate: -# - pwa-192x192.png -# - pwa-512x512.png -# - apple-touch-icon.png (180x180) -``` - -## License - -MIT +| `SPICEFLOW_DB` | spiceflow.db | SQLite path | +| `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions | diff --git a/client/CLAUDE.md b/client/CLAUDE.md new file mode 100644 index 0000000..41fad10 --- /dev/null +++ b/client/CLAUDE.md @@ -0,0 +1,103 @@ +# Client CLAUDE.md + +SvelteKit frontend PWA. + +## Commands + +```bash +npm install # First time +npm run dev # Dev server (localhost:5173) +npm run build # Production build +npm run check # TypeScript check +npm run check:watch # Watch mode +``` + +## Directory Structure + +``` +client/ +├── src/ +│ ├── routes/ # Pages (file-based routing) +│ │ ├── +layout.svelte +│ │ ├── +page.svelte # Home (session list) +│ │ └── session/[id]/+page.svelte +│ ├── lib/ +│ │ ├── api.ts # HTTP + WebSocket +│ │ ├── push.ts # Push notifications +│ │ ├── stores/sessions.ts # State management +│ │ └── components/ # UI components +│ ├── app.css # Tailwind +│ └── sw.ts # Service worker +├── static/ # Icons, manifest +├── vite.config.ts +└── tailwind.config.js +``` + +## Routing + +| File | URL | +|------|-----| +| `+page.svelte` | `/` | +| `session/[id]/+page.svelte` | `/session/:id` | +| `+layout.svelte` | Wraps all pages | + +Access URL params: `$page.params.id` + +## Stores + +```typescript +import { sessions, activeSession } from '$lib/stores/sessions'; + +// Sessions list +$sessions.sessions // Session[] +$sessions.loading // boolean +await sessions.load() +await sessions.create({ provider: 'claude' }) +await sessions.delete(id) + +// Active session +$activeSession.session // Session | null +$activeSession.messages // Message[] +$activeSession.streamingContent // string +$activeSession.pendingPermission // PermissionRequest | null +await activeSession.load(id) +await activeSession.sendMessage('text') +await activeSession.respondToPermission('accept') +``` + +## API Client + +```typescript +import { api, wsClient } from '$lib/api'; + +// HTTP +const sessions = await api.getSessions(); +await api.sendMessage(id, 'text'); + +// WebSocket +await wsClient.connect(); +wsClient.subscribe(id, (event) => { ... }); +``` + +## Components + +| Component | Purpose | +|-----------|---------| +| `MessageList` | Conversation display | +| `InputBar` | Text input + send | +| `PermissionRequest` | Accept/deny/steer UI | +| `FileDiff` | File changes preview | +| `SessionCard` | Session list item | +| `SessionSettings` | Gear menu | +| `TerminalView` | Tmux display | +| `PushToggle` | Push notification toggle | + +## Theme + +| Element | Class | +|---------|-------| +| Background | `bg-zinc-900` | +| Cards | `bg-zinc-800` | +| Text | `text-zinc-100` | +| Accent | `bg-orange-500` | +| Muted | `text-zinc-400` | diff --git a/client/package-lock.json b/client/package-lock.json index 3055a5b..7f10030 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,7 @@ "name": "spiceflow-client", "version": "0.1.0", "dependencies": { + "ansi-to-html": "^0.7.2", "marked": "^17.0.1" }, "devDependencies": { @@ -2830,6 +2831,21 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansi-to-html": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz", + "integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==", + "license": "MIT", + "dependencies": { + "entities": "^2.2.0" + }, + "bin": { + "ansi-to-html": "bin/ansi-to-html" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -3596,6 +3612,15 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", diff --git a/client/package.json b/client/package.json index 4ed8326..03709de 100644 --- a/client/package.json +++ b/client/package.json @@ -28,6 +28,7 @@ "workbox-window": "^7.0.0" }, "dependencies": { + "ansi-to-html": "^0.7.2", "marked": "^17.0.1" } } diff --git a/client/src/lib/CLAUDE.md b/client/src/lib/CLAUDE.md new file mode 100644 index 0000000..02abe47 --- /dev/null +++ b/client/src/lib/CLAUDE.md @@ -0,0 +1,115 @@ +# Lib CLAUDE.md + +Core library: API clients, stores, components. + +## Files + +| File | Purpose | +|------|---------| +| `api.ts` | HTTP client, WebSocket, types | +| `push.ts` | Push notification utilities | +| `stores/sessions.ts` | Session state management | +| `stores/push.ts` | Push notification state | +| `components/` | Svelte components | + +## Types (api.ts) + +```typescript +interface Session { + id: string; + provider: 'claude' | 'opencode' | 'tmux'; + title?: string; + status: 'idle' | 'processing' | 'awaiting-permission'; + 'working-dir'?: string; + 'auto-accept-edits'?: boolean; + 'pending-permission'?: PermissionRequest; +} + +interface Message { + id: string; + 'session-id': string; + role: 'user' | 'assistant' | 'system'; + content: string; + metadata?: Record; +} + +interface PermissionRequest { + tools: string[]; + denials: PermissionDenial[]; +} + +interface StreamEvent { + event?: string; + text?: string; + 'permission-request'?: PermissionRequest; +} +``` + +## API Client + +```typescript +// Sessions +api.getSessions() +api.getSession(id) +api.createSession({ provider: 'claude' }) +api.updateSession(id, { title: 'New' }) +api.deleteSession(id) + +// Messages +api.sendMessage(sessionId, 'text') +api.respondToPermission(sessionId, 'accept') + +// Terminal +api.getTerminalContent(sessionId) +api.sendTerminalInput(sessionId, 'ls\n') +``` + +## WebSocket + +```typescript +await wsClient.connect(); +const unsub = wsClient.subscribe(id, (event) => { + // content-delta, message-stop, permission-request +}); +unsub(); +``` + +## Stores + +**Sessions store:** +```typescript +sessions.load() // Fetch all +sessions.create(opts) // Create new +sessions.delete(id) // Delete +sessions.rename(id, title) +sessions.updateSession(id, data) // Local update +``` + +**Active session store:** +```typescript +activeSession.load(id) // Fetch + subscribe WS +activeSession.sendMessage(text) +activeSession.respondToPermission(response, message?) +activeSession.setAutoAcceptEdits(bool) +activeSession.clear() // Unsubscribe +``` + +## Component Events + +**InputBar:** +```svelte + e.detail.message} /> +``` + +**PermissionRequest:** +```svelte + { + e.detail.response // 'accept' | 'deny' | 'steer' + e.detail.message // optional steer text +}} /> +``` + +**SessionCard:** +```svelte + +``` diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 5ca95ed..f034be6 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -71,6 +71,30 @@ export interface StreamEvent { 'working-dir'?: string; 'permission-request'?: PermissionRequest; permissionRequest?: PermissionRequest; + diff?: TerminalDiff; +} + +// Terminal diff types for efficient TUI updates +export type TerminalDiffType = 'full' | 'diff' | 'unchanged'; + +export interface TerminalDiff { + type: TerminalDiffType; + lines?: string[]; // Full content as lines (for type: 'full') + changes?: Record; // Line number -> new content, null means line removed (for type: 'diff') + 'total-lines'?: number; + totalLines?: number; + hash?: number; + 'frame-id'?: number; // Auto-incrementing ID for ordering frames (prevents out-of-order issues) + frameId?: number; +} + +export interface TerminalContent { + content: string; + alive: boolean; + 'session-name': string; + sessionName?: string; + diff?: TerminalDiff; + layout?: 'desktop' | 'landscape' | 'portrait'; } class ApiClient { @@ -152,8 +176,12 @@ class ApiClient { } // Terminal (tmux) - async getTerminalContent(sessionId: string): Promise<{ content: string; alive: boolean; 'session-name': string }> { - return this.request<{ content: string; alive: boolean; 'session-name': string }>(`/sessions/${sessionId}/terminal`); + async getTerminalContent(sessionId: string, fresh: boolean = false): Promise { + // Add timestamp to bust browser cache + const params = new URLSearchParams(); + if (fresh) params.set('fresh', 'true'); + params.set('_t', Date.now().toString()); + return this.request(`/sessions/${sessionId}/terminal?${params.toString()}`); } async sendTerminalInput(sessionId: string, input: string): Promise<{ status: string }> { @@ -162,6 +190,13 @@ class ApiClient { body: JSON.stringify({ input }) }); } + + async resizeTerminal(sessionId: string, mode: 'desktop' | 'landscape' | 'portrait'): Promise<{ status: string; mode: string }> { + return this.request<{ status: string; mode: string }>(`/sessions/${sessionId}/terminal/resize`, { + method: 'POST', + body: JSON.stringify({ mode }) + }); + } } export const api = new ApiClient(); diff --git a/client/src/lib/components/MessageList.svelte b/client/src/lib/components/MessageList.svelte index 9f1fccf..ce63ac9 100644 --- a/client/src/lib/components/MessageList.svelte +++ b/client/src/lib/components/MessageList.svelte @@ -1,6 +1,6 @@ @@ -165,98 +367,130 @@ {:else} + +
{terminalContent || 'Terminal ready. Type a command below.'}
+ on:click={() => terminalInput?.focus()} + class="flex-1 min-h-0 overflow-auto p-3 font-mono text-green-400 whitespace-pre-wrap break-words leading-relaxed terminal-content cursor-text" + style="font-size: {fontScale * 0.875}rem;" + >{@html terminalHtml} -
+
+ class="px-1.5 py-0.5 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-[10px] font-mono text-zinc-200 transition-colors {ctrlMode ? 'font-bold ring-1 ring-cyan-400 rounded' : 'rounded-sm'}" + >^ + class="px-1.5 py-0.5 bg-red-600/80 hover:bg-red-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors" + >^C - + class="px-1.5 py-0.5 bg-amber-600/80 hover:bg-amber-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors" + >^D + + class="px-1.5 py-0.5 bg-green-600/80 hover:bg-green-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors" + >y - - {#each ['1', '2', '3', '4', '5'] as num} + class="px-1.5 py-0.5 bg-red-600/80 hover:bg-red-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors" + >n + + {#each ['1', '2', '3', '4'] as num} + class="px-1.5 py-0.5 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-zinc-200 transition-colors" + >{num} {/each} - + + class="px-1.5 py-0.5 bg-cyan-600/80 hover:bg-cyan-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors" + >⇥ + + + + {Math.round(fontScale * 100)}% + + + + + + +
- -
-
- $ - -
-
+ + {/if}
diff --git a/client/src/routes/CLAUDE.md b/client/src/routes/CLAUDE.md new file mode 100644 index 0000000..56f54f2 --- /dev/null +++ b/client/src/routes/CLAUDE.md @@ -0,0 +1,49 @@ +# Routes CLAUDE.md + +SvelteKit pages. + +## Files + +| File | URL | Purpose | +|------|-----|---------| +| `+layout.svelte` | All | Root layout, initializes stores | +| `+page.svelte` | `/` | Session list | +| `session/[id]/+page.svelte` | `/session/:id` | Session detail | + +## +layout.svelte + +- Loads sessions on mount +- Registers service worker +- Contains `` for pages + +## +page.svelte (Home) + +- Shows `$sortedSessions` list +- Create session button -> `sessions.create()` -> `goto('/session/id')` +- Delete via `sessions.delete(id)` + +## session/[id]/+page.svelte + +```svelte + +``` + +**Permission flow:** +1. `$activeSession.pendingPermission` becomes non-null +2. Show `` +3. User responds -> `activeSession.respondToPermission()` +4. Permission clears, streaming continues + +## Navigation + +```typescript +import { goto } from '$app/navigation'; +goto('/'); +goto(`/session/${id}`); +``` diff --git a/e2e/CLAUDE.md b/e2e/CLAUDE.md index 2580689..fd695b5 100644 --- a/e2e/CLAUDE.md +++ b/e2e/CLAUDE.md @@ -1,58 +1,86 @@ -# CLAUDE.md +# E2E CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Spiceflow E2E tests - Playwright-based end-to-end tests for the Spiceflow AI Session Orchestration PWA. Tests run against both the Clojure backend and SvelteKit frontend. +Playwright end-to-end tests. ## Commands ```bash -# Run all tests (starts both servers automatically) -npm test - -# Run with visible browser -npm run test:headed - -# Run with Playwright UI mode -npm run test:ui +npm test # Headless +npm run test:headed # Visible browser +npm run test:ui # Interactive UI +npx playwright test tests/basic.spec.ts # Specific file +npx playwright test -g "test name" # By name ``` -## Architecture +## Test Ports -The e2e setup automatically manages both servers: +| Service | Dev Port | E2E Port | +|---------|----------|----------| +| Backend | 3000 | 3001 | +| Frontend | 5173 | 5174 | +| Database | spiceflow.db | test-e2e.db | -1. **Global Setup** (`global-setup.ts`) - Starts backend (port 3001) and frontend (port 5174) -2. **Global Teardown** (`global-teardown.ts`) - Stops both servers -3. **Server Utils** (`server-utils.ts`) - Spawns Clojure and Vite processes, waits for health checks +## Directory Structure -E2E tests use different ports (3001/5174) than dev servers (3000/5173) to allow running tests without interfering with development. +``` +e2e/ +├── tests/ # Test files +├── global-setup.ts # Starts servers +├── global-teardown.ts # Stops servers +├── server-utils.ts # Server utilities +└── playwright.config.ts # Configuration +``` -Tests use Playwright's `page` fixture for browser interactions and `request` fixture for direct API calls. - -## Test Database - -E2E tests use a separate database (`server/test-e2e.db`) to avoid polluting the main database. - -## Writing Tests +## Test Pattern ```typescript import { test, expect } from '@playwright/test'; import { E2E_BACKEND_URL } from '../playwright.config'; test('example', async ({ page, request }) => { - // Direct API call - use E2E_BACKEND_URL for backend requests - const response = await request.get(`${E2E_BACKEND_URL}/api/sessions`); + // API setup + const res = await request.post(`${E2E_BACKEND_URL}/api/sessions`, { + data: { provider: 'claude' } + }); + const session = await res.json(); - // Browser interaction - baseURL is configured in playwright.config.ts - await page.goto('/'); - await expect(page.locator('h1')).toBeVisible(); + // Browser interaction + await page.goto(`/session/${session.id}`); + await page.fill('textarea', 'Hello'); + await page.click('button:has-text("Send")'); + + // Assertions + await expect(page.locator('.assistant-message')).toBeVisible({ timeout: 30000 }); + + // Cleanup + await request.delete(`${E2E_BACKEND_URL}/api/sessions/${session.id}`); }); ``` -## Parent Project +## Selectors -This is part of the Spiceflow monorepo. See `../CLAUDE.md` for full project documentation including: -- `../server/` - Clojure backend (Ring/Reitit, SQLite) -- `../client/` - SvelteKit PWA frontend +```typescript +page.locator('[data-testid="x"]') // By test ID +page.getByRole('button', { name: 'Send' }) +page.locator('text=Hello') +page.locator('.class') +``` + +## Waits + +```typescript +await expect(locator).toBeVisible(); +await expect(locator).toBeVisible({ timeout: 30000 }); +await expect(locator).toHaveText('Expected'); +await expect(locator).not.toBeVisible(); +``` + +## Test Files + +| File | Tests | +|------|-------| +| `basic.spec.ts` | Health, loading | +| `workflow-claude.spec.ts` | Claude session flow | +| `permissions-claude.spec.ts` | Permission handling | +| `autoaccept-claude.spec.ts` | Auto-accept edits | +| `tmux-terminal.spec.ts` | Tmux sessions | diff --git a/script/CLAUDE.md b/script/CLAUDE.md new file mode 100644 index 0000000..54388db --- /dev/null +++ b/script/CLAUDE.md @@ -0,0 +1,33 @@ +# Scripts CLAUDE.md + +Development scripts. + +## Scripts + +| Script | Purpose | +|--------|---------| +| `./script/dev` | Start backend + frontend | +| `./script/test` | Run E2E tests | + +## dev + +Starts: +1. Backend REPL on port 3000 with nREPL on 7888 +2. Auto-reload enabled +3. Frontend dev server on port 5173 + +Features: +- Ctrl+C stops everything +- Shows local IP for phone testing +- File changes auto-reload backend + +## test + +Runs Playwright tests with test database and ports. + +```bash +./script/test # All tests +./script/test -- --headed # Visible browser +./script/test -- --ui # Interactive +./script/test -- tests/file.ts # Specific file +``` diff --git a/script/dev b/script/dev index e86430a..7fd0dcd 100755 --- a/script/dev +++ b/script/dev @@ -13,11 +13,18 @@ NC='\033[0m' # No Color BACKEND_PID="" FRONTEND_PID="" +WATCHER_PID="" NREPL_PORT=7888 cleanup() { echo -e "\n${YELLOW}Shutting down...${NC}" + # Kill file watcher + if [ -n "$WATCHER_PID" ]; then + pkill -P $WATCHER_PID 2>/dev/null || true + kill $WATCHER_PID 2>/dev/null || true + fi + # Kill backend and all its children if [ -n "$BACKEND_PID" ]; then pkill -P $BACKEND_PID 2>/dev/null || true @@ -45,20 +52,32 @@ cleanup() { trap cleanup SIGINT SIGTERM SIGHUP EXIT +# Check for clj-nrepl-eval (required for auto-reload) +HAS_NREPL_EVAL=true +if ! command -v clj-nrepl-eval &> /dev/null; then + echo -e "${YELLOW}Warning: clj-nrepl-eval not found. Auto-reload will be disabled.${NC}" + HAS_NREPL_EVAL=false +fi + +# Check for inotifywait (preferred) or fall back to polling +HAS_INOTIFY=false +if command -v inotifywait &> /dev/null; then + HAS_INOTIFY=true +fi + echo -e "${BLUE}=== Starting Spiceflow Development Environment ===${NC}\n" -# Start backend REPL with auto-reload -echo -e "${GREEN}Starting backend REPL with auto-reload...${NC}" +# Start backend REPL +echo -e "${GREEN}Starting backend REPL...${NC}" cd "$ROOT_DIR/server" -# Start nREPL server and run (go) to start app with file watcher +# Start nREPL server and run (start) - no hawk watcher, we use inotifywait instead clj -M:dev -e " (require 'nrepl.server) (def server (nrepl.server/start-server :port $NREPL_PORT)) (println \"nREPL server started on port $NREPL_PORT\") (require 'user) -(user/go) -;; Block forever to keep the process running +(user/start) @(promise) " & BACKEND_PID=$! @@ -70,7 +89,40 @@ until curl -s http://localhost:3000/api/health > /dev/null 2>&1; do done echo -e "${GREEN}Backend ready on http://localhost:3000${NC}" echo -e "${GREEN}nREPL available on port $NREPL_PORT${NC}" -echo -e "${GREEN}Auto-reload enabled - editing .clj files will trigger reload${NC}" + +# Start file watcher for auto-reload +if [ "$HAS_NREPL_EVAL" = true ]; then + echo -e "${GREEN}Starting file watcher for auto-reload...${NC}" + if [ "$HAS_INOTIFY" = true ]; then + # Use inotifywait (efficient, event-based) + ( + cd "$ROOT_DIR/server" + while inotifywait -r -e modify,create,delete --include '.*\.clj$' src/ 2>/dev/null; do + echo -e "${YELLOW}File change detected, reloading...${NC}" + clj-nrepl-eval -p $NREPL_PORT "(user/reset)" > /dev/null 2>&1 & + done + ) & + WATCHER_PID=$! + else + # Fallback: polling with find (works everywhere) + ( + cd "$ROOT_DIR/server" + LAST_HASH="" + while true; do + CURRENT_HASH=$(find src -name '*.clj' -exec stat -c '%Y %n' {} \; 2>/dev/null | md5sum) + if [ -n "$LAST_HASH" ] && [ "$CURRENT_HASH" != "$LAST_HASH" ]; then + echo -e "${YELLOW}File change detected, reloading...${NC}" + clj-nrepl-eval -p $NREPL_PORT "(user/reset)" > /dev/null 2>&1 & + fi + LAST_HASH="$CURRENT_HASH" + sleep 2 + done + ) & + WATCHER_PID=$! + echo -e "${YELLOW}Using polling for file watching (install inotify-tools for better performance)${NC}" + fi + echo -e "${GREEN}Auto-reload enabled - editing .clj files will trigger reload${NC}" +fi # Start frontend echo -e "${GREEN}Starting frontend server...${NC}" @@ -93,7 +145,7 @@ echo -e "${GREEN}Backend:${NC} http://localhost:3000" echo -e "${GREEN}nREPL:${NC} localhost:$NREPL_PORT" echo -e "${GREEN}Frontend:${NC} https://localhost:5173" echo -e "${GREEN}Phone:${NC} https://${LOCAL_IP}:5173" -echo -e "\n${YELLOW}Auto-reload is active. Edit any .clj file to trigger reload.${NC}" +echo -e "\n${YELLOW}Edit any .clj file to trigger auto-reload.${NC}" echo -e "Press Ctrl+C to stop\n" # Wait for processes diff --git a/server/CLAUDE.md b/server/CLAUDE.md new file mode 100644 index 0000000..b9385e9 --- /dev/null +++ b/server/CLAUDE.md @@ -0,0 +1,87 @@ +# Server CLAUDE.md + +Clojure backend for Spiceflow. + +## Commands + +```bash +clj -M:dev # REPL with dev tools +clj -M:run # Production mode +clj -M:test # Unit tests +clj -M:test --focus ns # Specific namespace +``` + +**REPL commands** (in `dev/user.clj`): +```clojure +(go) ; Start server + auto-reload +(reset) ; Reload code + restart +(stop) ; Stop server +(reload) ; Reload code only +(reload-all) ; Force reload all namespaces +``` + +## Directory Structure + +``` +server/ +├── src/spiceflow/ +│ ├── core.clj # Entry point, Mount states +│ ├── config.clj # Configuration (aero) +│ ├── db/ # Database layer +│ ├── adapters/ # CLI integrations +│ ├── api/ # HTTP & WebSocket +│ ├── session/ # Session lifecycle +│ ├── push/ # Push notifications +│ └── terminal/ # Terminal diff caching +├── dev/user.clj # REPL helpers +├── test/ # Unit tests +├── resources/config.edn # Configuration +└── deps.edn # Dependencies +``` + +## Mount States (start order) + +1. `store` - SQLite database +2. `push` - Push notification store +3. `server` - Jetty HTTP server + +## Namespaces + +| Namespace | Purpose | +|-----------|---------| +| `spiceflow.core` | Entry point, Mount states | +| `spiceflow.config` | Configuration | +| `spiceflow.db.protocol` | DataStore protocol | +| `spiceflow.db.sqlite` | SQLite implementation | +| `spiceflow.db.memory` | In-memory (tests) | +| `spiceflow.adapters.protocol` | AgentAdapter protocol | +| `spiceflow.adapters.claude` | Claude Code CLI | +| `spiceflow.adapters.opencode` | OpenCode CLI | +| `spiceflow.adapters.tmux` | Tmux terminal | +| `spiceflow.api.routes` | HTTP handlers | +| `spiceflow.api.websocket` | WebSocket management | +| `spiceflow.session.manager` | Session lifecycle | + +## Configuration (aero) + +```edn +{:server {:port #long #or [#env SPICEFLOW_PORT 3000] + :host #or [#env SPICEFLOW_HOST "0.0.0.0"]} + :database {:type :sqlite + :dbname #or [#env SPICEFLOW_DB "spiceflow.db"]}} +``` + +Access: `(get-in config/config [:server :port])` + +## Conventions + +| Thing | Convention | +|-------|------------| +| Files | kebab-case | +| Namespaces | kebab-case | +| Functions | kebab-case | +| Protocols | PascalCase | +| Records | PascalCase | +| Private vars | `^:private` | + +Thread safety: `ConcurrentHashMap` for processes, `atom` for simpler state. diff --git a/server/src/spiceflow/CLAUDE.md b/server/src/spiceflow/CLAUDE.md new file mode 100644 index 0000000..c1f9fc7 --- /dev/null +++ b/server/src/spiceflow/CLAUDE.md @@ -0,0 +1,50 @@ +# Spiceflow Core CLAUDE.md + +## Files + +| File | Purpose | +|------|---------| +| `core.clj` | Entry point, Mount states, server wiring | +| `config.clj` | Configuration loading | + +## core.clj + +Mount states (start order): +1. `store` - SQLite via `sqlite/create-store` +2. `push` - Push store using same DB connection +3. `server` - Jetty with WebSocket support + +Server wiring: +```clojure +(ws/set-pending-permission-fn! (partial manager/get-pending-permission store)) +(manager/set-push-store! push) +(routes/create-app store ws/broadcast-to-session push) +``` + +## Subdirectories + +| Directory | Purpose | +|-----------|---------| +| `db/` | DataStore protocol + implementations | +| `adapters/` | AgentAdapter protocol + CLI integrations | +| `api/` | HTTP routes + WebSocket | +| `session/` | Session state machine | +| `push/` | Push notifications | +| `terminal/` | Terminal diff caching | + +## Patterns + +**Dependency injection via currying:** +```clojure +(defn create-app [store broadcast-fn push-store] + (let [handlers (make-handlers store broadcast-fn)] + (ring/ring-handler (router handlers)))) +``` + +**Async processing:** +```clojure +(defn handler [store broadcast-fn] + (fn [request] + (future (manager/stream-session-response ...)) + {:status 200 :body {:status "sent"}})) +``` diff --git a/server/src/spiceflow/adapters/CLAUDE.md b/server/src/spiceflow/adapters/CLAUDE.md new file mode 100644 index 0000000..0e77250 --- /dev/null +++ b/server/src/spiceflow/adapters/CLAUDE.md @@ -0,0 +1,71 @@ +# Adapters CLAUDE.md + +CLI integrations for Claude Code, OpenCode, and tmux. + +## Files + +| File | Purpose | +|------|---------| +| `protocol.clj` | AgentAdapter protocol | +| `claude.clj` | Claude Code CLI | +| `opencode.clj` | OpenCode CLI | +| `tmux.clj` | Tmux terminal | + +## AgentAdapter Protocol + +```clojure +(defprotocol AgentAdapter + (provider-name [this]) ; :claude, :opencode, :tmux + (discover-sessions [this]) ; Find existing sessions + (spawn-session [this session-id opts]) ; Start CLI process + (send-message [this handle message]) ; Write to stdin + (read-stream [this handle callback]) ; Read JSONL, call callback + (kill-process [this handle]) ; Terminate process + (parse-output [this line])) ; Parse JSONL line to event +``` + +## Process Handle + +All adapters return: +```clojure +{:process java.lang.Process + :stdin java.io.Writer + :stdout java.io.BufferedReader} +``` + +## Event Types + +| Event | Description | +|-------|-------------| +| `:init` | Process started, includes `:cwd` | +| `:content-delta` | Streaming text chunk | +| `:message-stop` | Message complete | +| `:permission-request` | Permission needed | +| `:working-dir-update` | CWD changed | +| `:error` | Error occurred | + +## Claude Adapter + +**Spawn command:** +```bash +claude --print --output-format stream-json \ + --resume \ + --allowedTools '["Write","Edit"]' \ + -- +``` + +**Session discovery:** Reads `~/.claude/projects/*/sessions/*.json` + +**Permission detection:** Looks for `tool_denied` in result event. + +## Tmux Adapter + +- No JSONL (raw terminal) +- Session names: `spiceflow-{adjective}-{noun}-{4digits}` +- Diff-based terminal updates + +## Adding an Adapter + +1. Implement `AgentAdapter` protocol in new file +2. Register in `api/routes.clj` `get-adapter` function +3. Add provider to `db/protocol.clj` `valid-session?` diff --git a/server/src/spiceflow/adapters/tmux.clj b/server/src/spiceflow/adapters/tmux.clj index cf58995..e42d9c9 100644 --- a/server/src/spiceflow/adapters/tmux.clj +++ b/server/src/spiceflow/adapters/tmux.clj @@ -252,16 +252,35 @@ [] (->TmuxAdapter)) +;; Pattern to match clear screen escape sequences +;; \x1b[H moves cursor home, \x1b[2J clears screen, \x1b[3J clears scrollback +(def ^:private clear-screen-pattern #"\u001b\[H\u001b\[2J|\u001b\[2J\u001b\[H|\u001b\[2J|\u001b\[3J") + +(defn- content-after-last-clear + "Return content after the last clear screen sequence, or full content if no clear found." + [content] + (if (str/blank? content) + content + (let [matcher (re-matcher clear-screen-pattern content)] + (loop [last-end nil] + (if (.find matcher) + (recur (.end matcher)) + (if last-end + (subs content last-end) + content)))))) + (defn capture-pane "Capture the current content of a tmux pane. - Returns the visible terminal content as a string, or nil if session doesn't exist." + Returns the visible terminal content as a string, or nil if session doesn't exist. + Preserves ANSI escape sequences for color rendering in the client. + Content before the last clear screen sequence is stripped." [session-name] (when session-name - ;; Use capture-pane with -p to print to stdout, -e to include escape sequences (then strip them) - ;; -S - and -E - captures the entire scrollback history - (let [result (shell/sh "tmux" "capture-pane" "-t" session-name "-p" "-S" "-1000")] + ;; Use capture-pane with -p to print to stdout, -e to include escape sequences + ;; -S -1000 captures scrollback history + (let [result (shell/sh "tmux" "capture-pane" "-t" session-name "-p" "-e" "-S" "-1000")] (when (zero? (:exit result)) - (strip-ansi (:out result)))))) + (content-after-last-clear (:out result)))))) (defn get-session-name "Get the tmux session name for a spiceflow session. @@ -317,3 +336,55 @@ (let [result (shell/sh "tmux" "rename-session" "-t" old-name new-name)] (when (zero? (:exit result)) new-name)))) + +;; Screen size presets for different device orientations +(def ^:private screen-sizes + {:desktop {:width 180 :height 50} + :landscape {:width 100 :height 30} + :portrait {:width 40 :height 35}}) + +(defn resize-session + "Resize a tmux session window to a preset size. + mode should be :desktop, :landscape, or :portrait. + Returns true on success, nil on failure." + [session-name mode] + (when (and session-name mode) + (let [{:keys [width height]} (get screen-sizes mode)] + (when (and width height) + (log/debug "[Tmux] Resizing session" session-name "to" mode "(" width "x" height ")") + ;; Resize the window - need to use resize-window on the session's window + (let [result (shell/sh "tmux" "resize-window" "-t" session-name "-x" (str width) "-y" (str height))] + (when (zero? (:exit result)) + true)))))) + +(defn get-window-size + "Get the current window size of a tmux session. + Returns {:width N :height M} or nil if session doesn't exist." + [session-name] + (when session-name + (let [result (shell/sh "tmux" "display-message" "-t" session-name "-p" "#{window_width}x#{window_height}")] + (when (zero? (:exit result)) + (let [output (str/trim (:out result)) + [width height] (str/split output #"x")] + (when (and width height) + {:width (parse-long width) + :height (parse-long height)})))))) + +(defn detect-layout-mode + "Detect the current layout mode based on tmux window size. + Returns :desktop, :landscape, :portrait, or nil if unknown." + [session-name] + (when-let [{:keys [width height]} (get-window-size session-name)] + ;; Find the closest matching preset + (let [presets (vec screen-sizes) + distances (map (fn [[mode {:keys [width w height h] :as preset-size}]] + (let [pw (:width preset-size) + ph (:height preset-size)] + {:mode mode + :distance (+ (Math/abs (- width pw)) + (Math/abs (- height ph)))})) + presets) + closest (first (sort-by :distance distances))] + ;; Only return the mode if it's an exact match or very close + (when (and closest (<= (:distance closest) 5)) + (:mode closest))))) diff --git a/server/src/spiceflow/api/CLAUDE.md b/server/src/spiceflow/api/CLAUDE.md new file mode 100644 index 0000000..3d99ec0 --- /dev/null +++ b/server/src/spiceflow/api/CLAUDE.md @@ -0,0 +1,68 @@ +# API CLAUDE.md + +HTTP and WebSocket handlers. + +## Files + +| File | Purpose | +|------|---------| +| `routes.clj` | HTTP endpoints | +| `websocket.clj` | WebSocket management | + +## Endpoints + +| Method | Path | Handler | +|--------|------|---------| +| GET | `/api/health` | `health-handler` | +| GET | `/api/sessions` | `get-sessions-handler` | +| POST | `/api/sessions` | `create-session-handler` | +| GET | `/api/sessions/:id` | `get-session-handler` | +| PATCH | `/api/sessions/:id` | `update-session-handler` | +| DELETE | `/api/sessions/:id` | `delete-session-handler` | +| POST | `/api/sessions/:id/send` | `send-message-handler` | +| POST | `/api/sessions/:id/permission` | `permission-handler` | +| GET | `/api/sessions/:id/terminal` | `get-terminal-handler` | +| POST | `/api/sessions/:id/terminal/input` | `send-terminal-input` | + +## Handler Pattern + +```clojure +(defn my-handler [store broadcast-fn] + (fn [request] + (let [id (get-in request [:path-params :id]) + body (:body request)] + {:status 200 :body {:result "ok"}}))) +``` + +## WebSocket + +**State:** +```clojure +(defonce ^:private all-connections (atom #{})) +(defonce ^:private connections (ConcurrentHashMap.)) +;; {session-id -> #{socket1 socket2}} +``` + +**Client sends:** +```json +{"type": "subscribe", "session-id": "uuid"} +{"type": "unsubscribe", "session-id": "uuid"} +``` + +**Server sends:** +```json +{"event": "subscribed", "session-id": "uuid"} +{"event": "content-delta", "text": "..."} +{"event": "message-stop"} +{"event": "permission-request", "permission-request": {...}} +{"event": "terminal-update", "content": "...", "diff": {...}} +{"event": "error", "message": "..."} +``` + +**Key functions:** +```clojure +(subscribe-to-session socket session-id) +(broadcast-to-session session-id message) +``` + +On subscribe, checks for pending permission and sends if exists. diff --git a/server/src/spiceflow/api/routes.clj b/server/src/spiceflow/api/routes.clj index fb3a30d..ac7af3e 100644 --- a/server/src/spiceflow/api/routes.clj +++ b/server/src/spiceflow/api/routes.clj @@ -9,6 +9,7 @@ [spiceflow.session.manager :as manager] [spiceflow.adapters.protocol :as adapter] [spiceflow.adapters.tmux :as tmux] + [spiceflow.terminal.diff :as terminal-diff] [spiceflow.push.protocol :as push-proto] [clojure.tools.logging :as log])) @@ -18,6 +19,15 @@ (-> (response/response body) (response/content-type "application/json"))) +(defn- json-response-no-cache + "Create a JSON response with no-cache headers (for polling endpoints)" + [body] + (-> (response/response body) + (response/content-type "application/json") + (response/header "Cache-Control" "no-store, no-cache, must-revalidate") + (response/header "Pragma" "no-cache") + (response/header "Expires" "0"))) + (defn- error-response "Create an error response" [status message] @@ -104,13 +114,15 @@ (let [id (get-in request [:path-params :id])] (log/debug "API request: delete-session" {:session-id id}) (if (tmux-session-id? id) - ;; Tmux session - just kill the tmux session + ;; Tmux session - kill the session and clean up cache (if (tmux/session-alive? id) (do (log/debug "Killing tmux session:" id) (let [tmux-adapter (manager/get-adapter :tmux)] (adapter/kill-process tmux-adapter {:session-name id :output-file (str "/tmp/spiceflow-tmux-" id ".log")})) + ;; Clean up terminal diff cache + (terminal-diff/invalidate-cache id) (response/status (response/response nil) 204)) (error-response 404 "Session not found")) ;; Regular DB session @@ -213,22 +225,36 @@ ;; Tmux terminal handlers (defn terminal-capture-handler "Get the current terminal content for a tmux session. - For ephemeral tmux sessions, the session ID IS the tmux session name." + For ephemeral tmux sessions, the session ID IS the tmux session name. + Returns diff information for efficient updates. + Pass ?fresh=true to force a fresh capture (invalidates cache first)." [_store] (fn [request] - (let [id (get-in request [:path-params :id])] + (let [id (get-in request [:path-params :id]) + fresh? (= "true" (get-in request [:query-params "fresh"]))] (if (tmux-session-id? id) ;; Ephemeral tmux session - ID is the session name (if (tmux/session-alive? id) - (let [content (tmux/capture-pane id)] - (json-response {:content (or content "") - :alive true - :session-name id})) - (error-response 404 "Session not found")) + (do + ;; If fresh=true, invalidate cache to ensure full content is returned + (when fresh? + (terminal-diff/invalidate-cache id)) + (let [{:keys [content diff]} (terminal-diff/capture-with-diff id tmux/capture-pane) + layout (tmux/detect-layout-mode id)] + (json-response-no-cache {:content (or content "") + :alive true + :session-name id + :diff diff + :layout (when layout (name layout))}))) + (do + ;; Session died - invalidate cache + (terminal-diff/invalidate-cache id) + (error-response 404 "Session not found"))) (error-response 400 "Not a tmux session"))))) (defn terminal-input-handler - "Send raw input to a tmux session (stdin-style)" + "Send raw input to a tmux session (stdin-style). + Broadcasts diff-based terminal updates after input." [_store broadcast-fn] (fn [request] (let [id (get-in request [:path-params :id]) @@ -238,21 +264,45 @@ (if (tmux/session-alive? id) (do (tmux/send-keys-raw id input) - ;; Broadcast terminal update after input + ;; Broadcast terminal update with diff after input (future (Thread/sleep 100) ;; Small delay to let terminal update - (let [content (tmux/capture-pane id)] - (broadcast-fn id {:event :terminal-update - :content (or content "")}))) + (let [{:keys [content diff changed]} (terminal-diff/capture-with-diff id tmux/capture-pane)] + (when changed + (broadcast-fn id {:event :terminal-update + :content (or content "") + :diff diff})))) (json-response {:status "sent"})) (error-response 400 "Tmux session not alive")) (error-response 400 "Not a tmux session"))))) +(defn terminal-resize-handler + "Resize a tmux session to a preset screen size. + Mode can be: desktop, landscape, or portrait." + [_store] + (fn [request] + (let [id (get-in request [:path-params :id]) + mode (keyword (get-in request [:body :mode]))] + (if (tmux-session-id? id) + (if (tmux/session-alive? id) + (if (#{:desktop :landscape :portrait} mode) + (if (tmux/resize-session id mode) + (json-response {:status "resized" :mode (name mode)}) + (error-response 500 "Failed to resize tmux session")) + (error-response 400 "Invalid mode. Must be: desktop, landscape, or portrait")) + (error-response 400 "Tmux session not alive")) + (error-response 400 "Not a tmux session"))))) + ;; Health check (defn health-handler [_request] (json-response {:status "ok" :service "spiceflow"})) +;; Test endpoint for verifying hot reload +(defn ping-handler + [_request] + (json-response {:pong true :time (str (java.time.Instant/now))})) + ;; Push notification handlers (defn vapid-key-handler "Return the public VAPID key for push subscriptions" @@ -295,6 +345,7 @@ [store broadcast-fn push-store] [["/api" ["/health" {:get health-handler}] + ["/ping" {:get ping-handler}] ["/sessions" {:get (list-sessions-handler store) :post (create-session-handler store)}] ["/sessions/:id" {:get (get-session-handler store) @@ -304,6 +355,7 @@ ["/sessions/:id/permission" {:post (permission-response-handler store broadcast-fn)}] ["/sessions/:id/terminal" {:get (terminal-capture-handler store)}] ["/sessions/:id/terminal/input" {:post (terminal-input-handler store broadcast-fn)}] + ["/sessions/:id/terminal/resize" {:post (terminal-resize-handler store)}] ["/push/vapid-key" {:get (vapid-key-handler push-store)}] ["/push/subscribe" {:post (subscribe-handler push-store)}] ["/push/unsubscribe" {:post (unsubscribe-handler push-store)}]]]) @@ -312,7 +364,8 @@ "Create the Ring application" [store broadcast-fn push-store] (-> (ring/ring-handler - (ring/router (create-routes store broadcast-fn push-store)) + (ring/router (create-routes store broadcast-fn push-store) + {:data {:middleware [parameters/parameters-middleware]}}) (ring/create-default-handler)) (wrap-json-body {:keywords? true}) wrap-json-response diff --git a/server/src/spiceflow/db/CLAUDE.md b/server/src/spiceflow/db/CLAUDE.md new file mode 100644 index 0000000..130e380 --- /dev/null +++ b/server/src/spiceflow/db/CLAUDE.md @@ -0,0 +1,92 @@ +# Database CLAUDE.md + +## Files + +| File | Purpose | +|------|---------| +| `protocol.clj` | DataStore protocol definition | +| `sqlite.clj` | SQLite implementation | +| `memory.clj` | In-memory (tests) | + +## DataStore Protocol + +```clojure +(defprotocol DataStore + (get-sessions [this]) + (get-session [this id]) + (save-session [this session]) + (update-session [this id data]) + (delete-session [this id]) + (get-messages [this session-id]) + (save-message [this message]) + (get-message [this id]) + (update-message [this id data])) +``` + +## Schema + +```sql +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, -- 'claude', 'opencode', 'tmux' + external_id TEXT, + title TEXT, + working_dir TEXT, + spawn_dir TEXT, + status_id INTEGER DEFAULT 1, -- FK to session_statuses + pending_permission TEXT, -- JSON + auto_accept_edits INTEGER DEFAULT 0, + created_at TEXT, + updated_at TEXT +); + +CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT REFERENCES sessions(id), + role TEXT NOT NULL, -- 'user', 'assistant', 'system' + content TEXT, + metadata TEXT, -- JSON + created_at TEXT +); + +CREATE TABLE session_statuses ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE -- 'idle', 'processing', 'awaiting-permission' +); +``` + +## Data Shapes + +**Session:** +```clojure +{:id "uuid" + :provider :claude ; :opencode, :tmux + :external-id "cli-session-id" + :title "Fixing bug" + :working-dir "/path" + :spawn-dir "/path" + :status :idle ; :processing, :awaiting-permission + :pending-permission {...} + :auto-accept-edits true} +``` + +**Message:** +```clojure +{:id "uuid" + :session-id "session-uuid" + :role :user ; :assistant, :system + :content "text" + :metadata {:tools ["Write"] :status "accept"}} +``` + +## Naming Conversion + +Clojure kebab-case <-> DB snake_case handled by `row->session` and `session->row`. + +## Migrations + +Add to `migrations` vector in `sqlite.clj`: +```clojure +(def migrations + ["ALTER TABLE sessions ADD COLUMN new_field TEXT"]) +``` diff --git a/server/src/spiceflow/session/CLAUDE.md b/server/src/spiceflow/session/CLAUDE.md new file mode 100644 index 0000000..a1024f1 --- /dev/null +++ b/server/src/spiceflow/session/CLAUDE.md @@ -0,0 +1,75 @@ +# Session CLAUDE.md + +Session lifecycle and state machine. + +## Files + +| File | Purpose | +|------|---------| +| `manager.clj` | Session state machine, streaming, permissions | + +## Session States + +``` +idle -> processing -> awaiting-permission -> idle + ^ | + +--------------------+ +``` + +| State | Description | +|-------|-------------| +| `idle` | No active process | +| `processing` | CLI running, streaming | +| `awaiting-permission` | Paused for permission | + +## Active Processes + +```clojure +(defonce ^:private active-processes (ConcurrentHashMap.)) +;; {session-id -> {:process ... :stdin ... :stdout ...}} +``` + +## Key Functions + +**`start-session [store session-id opts]`** - Spawn CLI process, add to active-processes. + +**`send-message-to-session [store session-id message]`** - Save message, send to stdin. + +**`stream-session-response [store session-id broadcast-fn]`** - Read JSONL, broadcast events, handle permissions. + +**`respond-to-permission [store session-id response message broadcast-fn]`** - Handle accept/deny/steer. + +## Permission Flow + +1. CLI outputs permission request in result event +2. Save permission message with status "pending" +3. Set `pending-permission` on session +4. Status -> `:awaiting-permission` +5. Broadcast `:permission-request` event +6. User responds via `/api/sessions/:id/permission` +7. Spawn new process with `--resume --allowedTools` +8. Send response message ("continue" or steer text) +9. Continue streaming + +## Auto-Accept + +```clojure +(defn should-auto-accept? [session permission] + (and (:auto-accept-edits session) + (every? #{"Write" "Edit"} (:tools permission)))) +``` + +## REPL Debugging + +```clojure +@#'m/active-processes ; See running sessions +(.get @#'m/active-processes "id") ; Specific session +(db/get-session store "id") ; Check status/pending +``` + +Force cleanup: +```clojure +(.destroyForcibly (:process (.get @#'m/active-processes "id"))) +(.remove @#'m/active-processes "id") +(db/update-session store "id" {:status :idle :pending-permission :clear}) +``` diff --git a/server/src/spiceflow/terminal/diff.clj b/server/src/spiceflow/terminal/diff.clj new file mode 100644 index 0000000..43b6e63 --- /dev/null +++ b/server/src/spiceflow/terminal/diff.clj @@ -0,0 +1,172 @@ +(ns spiceflow.terminal.diff + "Terminal content diffing with caching. + + Maintains a ConcurrentHashMap of terminal state per session and computes + line-based diffs to minimize data transfer. Supports TUI applications + where any line can change on screen refresh. + + Includes auto-incrementing frame IDs to prevent out-of-order frame issues + when reducing batch timers." + (:require [clojure.string :as str] + [clojure.tools.logging :as log]) + (:import [java.util.concurrent ConcurrentHashMap] + [java.util.concurrent.atomic AtomicLong])) + +;; Cache storing terminal state per session +;; Key: session-name (string) +;; Value: {:lines [vector of strings] :hash int :frame-id long} +(defonce ^:private terminal-cache (ConcurrentHashMap.)) + +;; Global frame counter - auto-incrementing sequence for ordering frames +;; Uses AtomicLong for thread safety. Max value is 2^53-1 (JS MAX_SAFE_INTEGER) +;; to ensure safe handling on the client side +(def ^:private max-frame-id 9007199254740991) ;; 2^53-1 (JS MAX_SAFE_INTEGER) +(defonce ^:private frame-counter (AtomicLong. 0)) + +(defn- next-frame-id + "Get the next frame ID, handling overflow by wrapping to 0. + Returns a monotonically increasing value (except on wrap)." + [] + (loop [] + (let [current (.get frame-counter) + next-val (if (>= current max-frame-id) 0 (inc current))] + (if (.compareAndSet frame-counter current next-val) + next-val + (recur))))) + +(defn- content->lines + "Split terminal content into lines, preserving empty lines." + [content] + (if (str/blank? content) + [] + (str/split-lines content))) + +(defn- lines->content + "Join lines back into content string." + [lines] + (str/join "\n" lines)) + +(defn- compute-line-diff + "Compute line-by-line diff between old and new line vectors. + Returns a map of {:line-num content} for changed lines. + + For efficiency, if more than 50% of lines changed, returns nil + to signal that a full refresh is more efficient." + [old-lines new-lines] + (let [old-count (count old-lines) + new-count (count new-lines) + max-count (max old-count new-count) + changes (volatile! (transient {}))] + (when (pos? max-count) + ;; Compare existing lines + (dotimes [i max-count] + (let [old-line (get old-lines i) + new-line (get new-lines i)] + (when (not= old-line new-line) + (vswap! changes assoc! i new-line)))) + (let [diff (persistent! @changes) + change-count (count diff)] + ;; If more than 50% changed, full refresh is more efficient + (when (and (pos? change-count) + (< (/ change-count max-count) 0.5)) + diff))))) + +(defn- compute-diff + "Compute diff between cached state and new content. + + Returns one of: + - {:type :unchanged :frame-id n} - no changes + - {:type :diff :changes {line-num content} :total-lines n :frame-id n} - partial update + - {:type :full :lines [lines] :total-lines n :frame-id n} - full refresh needed + + All responses include :frame-id for ordering. Unchanged responses use + the cached frame-id since content hasn't changed." + [cached new-content] + (let [new-lines (content->lines new-content) + new-count (count new-lines) + new-hash (hash new-content)] + (cond + ;; No cached state - full refresh + (nil? cached) + (let [frame-id (next-frame-id)] + {:type :full + :lines new-lines + :total-lines new-count + :hash new-hash + :frame-id frame-id}) + + ;; Content unchanged (fast path via hash) + (= (:hash cached) new-hash) + {:type :unchanged + :total-lines new-count + :hash new-hash + :frame-id (:frame-id cached)} ;; Reuse cached frame-id for unchanged + + ;; Compute line diff + :else + (let [old-lines (:lines cached) + line-diff (compute-line-diff old-lines new-lines) + frame-id (next-frame-id)] + (if line-diff + ;; Partial diff is efficient + {:type :diff + :changes line-diff + :total-lines new-count + :hash new-hash + :frame-id frame-id} + ;; Too many changes - send full + {:type :full + :lines new-lines + :total-lines new-count + :hash new-hash + :frame-id frame-id}))))) + +(defn capture-with-diff + "Capture terminal content and compute diff from cached state. + + Arguments: + - session-name: tmux session identifier + - capture-fn: function that takes session-name and returns content string + + Returns map with: + - :content - full content string (always included for GET requests) + - :diff - diff payload for WebSocket updates (includes :frame-id) + - :changed - boolean indicating if content changed" + [session-name capture-fn] + (let [new-content (capture-fn session-name) + cached (.get terminal-cache session-name) + diff-result (compute-diff cached new-content)] + ;; Update cache if changed (store frame-id for unchanged responses) + (when (not= :unchanged (:type diff-result)) + (.put terminal-cache session-name + {:lines (content->lines new-content) + :hash (:hash diff-result) + :frame-id (:frame-id diff-result)})) + {:content new-content + :diff diff-result + :changed (not= :unchanged (:type diff-result))})) + +(defn get-cached-content + "Get cached content for a session without capturing. + Returns nil if not cached." + [session-name] + (when-let [cached (.get terminal-cache session-name)] + (lines->content (:lines cached)))) + +(defn invalidate-cache + "Remove a session from the cache." + [session-name] + (.remove terminal-cache session-name) + (log/debug "[TerminalDiff] Invalidated cache for" session-name)) + +(defn clear-cache + "Clear all cached terminal state." + [] + (.clear terminal-cache) + (log/debug "[TerminalDiff] Cleared all terminal cache")) + +(defn cache-stats + "Get cache statistics for debugging." + [] + {:session-count (.size terminal-cache) + :sessions (vec (.keySet terminal-cache))})