diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..74465e5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,42 @@
+# Dependencies
+node_modules/
+.pnpm-store/
+
+# Build outputs
+client/build/
+client/.svelte-kit/
+target/
+classes/
+
+# Database
+*.db
+*.sqlite
+
+# Environment
+.env
+.env.local
+.env.*.local
+
+# IDE
+.idea/
+*.iml
+.vscode/
+*.swp
+*.swo
+
+# Logs
+*.log
+logs/
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Clojure
+.cpcache/
+.nrepl-port
+.lsp/
+.clj-kondo/
+
+# PWA
+client/dev-dist/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a60391a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,154 @@
+# Spiceflow
+
+AI Session Orchestration PWA for monitoring and interacting with Claude Code and OpenCode sessions.
+
+> "The spice must flow."
+
+## Architecture
+
+```
+┌─────────────────┐ ┌─────────────────────────┐ ┌─────────────────┐
+│ Claude Code │◀───▶│ Spiceflow Server │◀───▶│ PWA Client │
+│ (CLI) │ │ (Clojure) │ │ (Svelte) │
+└─────────────────┘ │ │ └─────────────────┘
+ │ ┌─────────────────┐ │
+┌─────────────────┐ │ │ SQLite + DB │ │
+│ OpenCode │◀───▶│ │ Abstraction │ │
+│ (CLI) │ │ │ WebSocket/SSE │ │
+└─────────────────┘ │ └─────────────────┘ │
+ └─────────────────────────┘
+```
+
+## Quick Start
+
+### Prerequisites
+
+- Clojure CLI (deps.edn)
+- Node.js 18+ and pnpm
+- SQLite
+
+### Server
+
+```bash
+cd server
+clj -M:run
+```
+
+The server will start on http://localhost:3000.
+
+### Client
+
+```bash
+cd client
+pnpm install
+pnpm dev
+```
+
+The client dev server will start on http://localhost:5173 with proxy to the API.
+
+### Production Build
+
+```bash
+# Build client
+cd client
+pnpm build
+
+# Run server (serves static files from client/build)
+cd ../server
+clj -M:run
+```
+
+## 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 |
+
+## Development
+
+### Running Tests
+
+```bash
+# Server tests
+cd server
+clj -M:test
+
+# Client type checking
+cd client
+pnpm check
+```
+
+### 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
+```
+
+## Configuration
+
+### Server
+
+Configuration via `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 |
+
+### 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
diff --git a/client/package.json b/client/package.json
new file mode 100644
index 0000000..9aa1aa9
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "spiceflow-client",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-static": "^3.0.1",
+ "@sveltejs/kit": "^2.5.0",
+ "@sveltejs/vite-plugin-svelte": "^3.0.2",
+ "@types/node": "^20.11.0",
+ "autoprefixer": "^10.4.17",
+ "postcss": "^8.4.33",
+ "svelte": "^4.2.9",
+ "svelte-check": "^3.6.3",
+ "tailwindcss": "^3.4.1",
+ "tslib": "^2.6.2",
+ "typescript": "^5.3.3",
+ "vite": "^5.0.12",
+ "vite-plugin-pwa": "^0.19.2",
+ "workbox-window": "^7.0.0"
+ },
+ "dependencies": {}
+}
diff --git a/client/postcss.config.js b/client/postcss.config.js
new file mode 100644
index 0000000..0f77216
--- /dev/null
+++ b/client/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {}
+ }
+};
diff --git a/client/src/app.css b/client/src/app.css
new file mode 100644
index 0000000..5402d72
--- /dev/null
+++ b/client/src/app.css
@@ -0,0 +1,64 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ html {
+ -webkit-tap-highlight-color: transparent;
+ }
+
+ body {
+ @apply antialiased;
+ }
+
+ ::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+
+ ::-webkit-scrollbar-track {
+ @apply bg-zinc-800;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ @apply bg-zinc-600 rounded-full;
+ }
+
+ ::-webkit-scrollbar-thumb:hover {
+ @apply bg-zinc-500;
+ }
+}
+
+@layer components {
+ .btn {
+ @apply px-4 py-2 rounded-lg font-medium transition-colors;
+ }
+
+ .btn-primary {
+ @apply bg-spice-500 hover:bg-spice-600 text-white;
+ }
+
+ .btn-secondary {
+ @apply bg-zinc-700 hover:bg-zinc-600 text-zinc-100;
+ }
+
+ .card {
+ @apply bg-zinc-800 rounded-xl p-4 border border-zinc-700;
+ }
+
+ .input {
+ @apply w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg
+ text-zinc-100 placeholder:text-zinc-500
+ focus:outline-none focus:ring-2 focus:ring-spice-500 focus:border-transparent;
+ }
+}
+
+@layer utilities {
+ .safe-bottom {
+ padding-bottom: env(safe-area-inset-bottom, 1rem);
+ }
+
+ .safe-top {
+ padding-top: env(safe-area-inset-top, 0);
+ }
+}
diff --git a/client/src/app.d.ts b/client/src/app.d.ts
new file mode 100644
index 0000000..743f07b
--- /dev/null
+++ b/client/src/app.d.ts
@@ -0,0 +1,13 @@
+// See https://kit.svelte.dev/docs/types#app
+// for information about these interfaces
+declare global {
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/client/src/app.html b/client/src/app.html
new file mode 100644
index 0000000..9a43ff5
--- /dev/null
+++ b/client/src/app.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts
new file mode 100644
index 0000000..8ebf823
--- /dev/null
+++ b/client/src/lib/api.ts
@@ -0,0 +1,236 @@
+const API_BASE = '/api';
+
+export interface Session {
+ id: string;
+ provider: 'claude' | 'opencode';
+ 'external-id'?: string;
+ externalId?: string;
+ title?: string;
+ 'working-dir'?: string;
+ workingDir?: string;
+ status: 'idle' | 'running' | 'completed';
+ 'created-at'?: string;
+ createdAt?: string;
+ 'updated-at'?: string;
+ updatedAt?: string;
+ messages?: Message[];
+}
+
+export interface Message {
+ id: string;
+ 'session-id': string;
+ sessionId?: string;
+ role: 'user' | 'assistant' | 'system';
+ content: string;
+ metadata?: Record;
+ 'created-at'?: string;
+ createdAt?: string;
+}
+
+export interface DiscoveredSession {
+ 'external-id': string;
+ provider: 'claude' | 'opencode';
+ title?: string;
+ 'working-dir'?: string;
+ 'file-path'?: string;
+}
+
+export interface StreamEvent {
+ event?: string;
+ 'session-id'?: string;
+ sessionId?: string;
+ text?: string;
+ content?: string;
+ type?: string;
+ message?: string;
+}
+
+class ApiClient {
+ private baseUrl: string;
+
+ constructor(baseUrl: string = API_BASE) {
+ this.baseUrl = baseUrl;
+ }
+
+ private async request(path: string, options?: RequestInit): Promise {
+ const response = await fetch(`${this.baseUrl}${path}`, {
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options?.headers
+ }
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
+ throw new Error(error.error || `HTTP ${response.status}`);
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return response.json();
+ }
+
+ // Sessions
+ async getSessions(): Promise {
+ return this.request('/sessions');
+ }
+
+ async getSession(id: string): Promise {
+ return this.request(`/sessions/${id}`);
+ }
+
+ async createSession(session: Partial): Promise {
+ return this.request('/sessions', {
+ method: 'POST',
+ body: JSON.stringify(session)
+ });
+ }
+
+ async deleteSession(id: string): Promise {
+ await this.request(`/sessions/${id}`, { method: 'DELETE' });
+ }
+
+ async sendMessage(sessionId: string, message: string): Promise<{ status: string }> {
+ return this.request<{ status: string }>(`/sessions/${sessionId}/send`, {
+ method: 'POST',
+ body: JSON.stringify({ message })
+ });
+ }
+
+ // Discovery
+ async discoverClaude(): Promise {
+ return this.request('/discover/claude');
+ }
+
+ async discoverOpenCode(): Promise {
+ return this.request('/discover/opencode');
+ }
+
+ async importSession(session: DiscoveredSession): Promise {
+ return this.request('/import', {
+ method: 'POST',
+ body: JSON.stringify(session)
+ });
+ }
+
+ // Health
+ async health(): Promise<{ status: string; service: string }> {
+ return this.request<{ status: string; service: string }>('/health');
+ }
+}
+
+export const api = new ApiClient();
+
+// WebSocket connection
+export class WebSocketClient {
+ private ws: WebSocket | null = null;
+ private url: string;
+ private reconnectAttempts = 0;
+ private maxReconnectAttempts = 5;
+ private reconnectDelay = 1000;
+ private listeners: Map void>> = new Map();
+ private globalListeners: Set<(event: StreamEvent) => void> = new Set();
+
+ constructor(url: string = `ws://${window.location.host}/api/ws`) {
+ this.url = url;
+ }
+
+ connect(): Promise {
+ return new Promise((resolve, reject) => {
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ resolve();
+ return;
+ }
+
+ this.ws = new WebSocket(this.url);
+
+ this.ws.onopen = () => {
+ console.log('WebSocket connected');
+ this.reconnectAttempts = 0;
+ resolve();
+ };
+
+ this.ws.onclose = () => {
+ console.log('WebSocket disconnected');
+ this.attemptReconnect();
+ };
+
+ this.ws.onerror = (error) => {
+ console.error('WebSocket error:', error);
+ reject(error);
+ };
+
+ this.ws.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data) as StreamEvent;
+ this.handleMessage(data);
+ } catch (e) {
+ console.error('Failed to parse WebSocket message:', e);
+ }
+ };
+ });
+ }
+
+ private handleMessage(event: StreamEvent) {
+ // Notify global listeners
+ this.globalListeners.forEach((listener) => listener(event));
+
+ // Notify session-specific listeners
+ const sessionId = event['session-id'] || event.sessionId;
+ if (sessionId) {
+ const sessionListeners = this.listeners.get(sessionId);
+ sessionListeners?.forEach((listener) => listener(event));
+ }
+ }
+
+ private attemptReconnect() {
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
+ console.error('Max reconnection attempts reached');
+ return;
+ }
+
+ this.reconnectAttempts++;
+ const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
+
+ setTimeout(() => {
+ console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
+ this.connect().catch(() => {});
+ }, delay);
+ }
+
+ subscribe(sessionId: string, callback: (event: StreamEvent) => void) {
+ if (!this.listeners.has(sessionId)) {
+ this.listeners.set(sessionId, new Set());
+ }
+ this.listeners.get(sessionId)!.add(callback);
+
+ // Send subscribe message
+ this.send({ type: 'subscribe', 'session-id': sessionId });
+
+ return () => {
+ this.listeners.get(sessionId)?.delete(callback);
+ this.send({ type: 'unsubscribe', 'session-id': sessionId });
+ };
+ }
+
+ onMessage(callback: (event: StreamEvent) => void) {
+ this.globalListeners.add(callback);
+ return () => this.globalListeners.delete(callback);
+ }
+
+ send(data: Record) {
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(data));
+ }
+ }
+
+ disconnect() {
+ this.ws?.close();
+ this.ws = null;
+ }
+}
+
+export const wsClient = new WebSocketClient();
diff --git a/client/src/lib/components/InputBar.svelte b/client/src/lib/components/InputBar.svelte
new file mode 100644
index 0000000..766bd87
--- /dev/null
+++ b/client/src/lib/components/InputBar.svelte
@@ -0,0 +1,65 @@
+
+
+
diff --git a/client/src/lib/components/MessageList.svelte b/client/src/lib/components/MessageList.svelte
new file mode 100644
index 0000000..39e26dc
--- /dev/null
+++ b/client/src/lib/components/MessageList.svelte
@@ -0,0 +1,114 @@
+
+
+
+ {#if messages.length === 0 && !streamingContent}
+
+
+
+
No messages yet
+
Send a message to start the conversation
+
+
+ {:else}
+ {#each messages as message (message.id)}
+
+
+
+ {roleLabels[message.role]}
+
+
+
+ {message.content}
+
+
+ {/each}
+
+ {#if streamingContent}
+
+
+
+ Assistant
+
+
+
+
+
+
+
+
+ {streamingContent}|
+
+
+ {/if}
+ {/if}
+
diff --git a/client/src/lib/components/SessionCard.svelte b/client/src/lib/components/SessionCard.svelte
new file mode 100644
index 0000000..6fe78bc
--- /dev/null
+++ b/client/src/lib/components/SessionCard.svelte
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+ {session.provider}
+
+
+
+
+ {session.title || `Session ${shortId}`}
+
+
+ {#if projectName}
+
+ {projectName}
+
+ {/if}
+
+
+
+ {formatTime(updatedAt)}
+
+
+
diff --git a/client/src/lib/index.ts b/client/src/lib/index.ts
new file mode 100644
index 0000000..4d9ace8
--- /dev/null
+++ b/client/src/lib/index.ts
@@ -0,0 +1,3 @@
+// Spiceflow client library exports
+export * from './api';
+export * from './stores/sessions';
diff --git a/client/src/lib/stores/sessions.ts b/client/src/lib/stores/sessions.ts
new file mode 100644
index 0000000..5e5615c
--- /dev/null
+++ b/client/src/lib/stores/sessions.ts
@@ -0,0 +1,207 @@
+import { writable, derived, type Readable } from 'svelte/store';
+import { api, wsClient, type Session, type Message, type StreamEvent } from '$lib/api';
+
+interface SessionsState {
+ sessions: Session[];
+ loading: boolean;
+ error: string | null;
+}
+
+interface ActiveSessionState {
+ session: Session | null;
+ messages: Message[];
+ streamingContent: string;
+ loading: boolean;
+ error: string | null;
+}
+
+function createSessionsStore() {
+ const { subscribe, set, update } = writable({
+ sessions: [],
+ loading: false,
+ error: null
+ });
+
+ return {
+ subscribe,
+ async load() {
+ update((s) => ({ ...s, loading: true, error: null }));
+ try {
+ const sessions = await api.getSessions();
+ update((s) => ({ ...s, sessions, loading: false }));
+ } catch (e) {
+ update((s) => ({ ...s, loading: false, error: (e as Error).message }));
+ }
+ },
+ async delete(id: string) {
+ try {
+ await api.deleteSession(id);
+ update((s) => ({
+ ...s,
+ sessions: s.sessions.filter((session) => session.id !== id)
+ }));
+ } catch (e) {
+ update((s) => ({ ...s, error: (e as Error).message }));
+ }
+ },
+ updateSession(id: string, data: Partial) {
+ update((s) => ({
+ ...s,
+ sessions: s.sessions.map((session) =>
+ session.id === id ? { ...session, ...data } : session
+ )
+ }));
+ }
+ };
+}
+
+function createActiveSessionStore() {
+ const { subscribe, set, update } = writable({
+ session: null,
+ messages: [],
+ streamingContent: '',
+ loading: false,
+ error: null
+ });
+
+ let unsubscribeWs: (() => void) | null = null;
+
+ return {
+ subscribe,
+ async load(id: string) {
+ update((s) => ({ ...s, loading: true, error: null, streamingContent: '' }));
+
+ // Unsubscribe from previous session
+ if (unsubscribeWs) {
+ unsubscribeWs();
+ unsubscribeWs = null;
+ }
+
+ try {
+ const session = await api.getSession(id);
+ update((s) => ({
+ ...s,
+ session,
+ messages: session.messages || [],
+ loading: false
+ }));
+
+ // Subscribe to WebSocket updates
+ await wsClient.connect();
+ unsubscribeWs = wsClient.subscribe(id, (event) => {
+ handleStreamEvent(event);
+ });
+ } catch (e) {
+ update((s) => ({ ...s, loading: false, error: (e as Error).message }));
+ }
+ },
+ async sendMessage(message: string) {
+ const state = get();
+ if (!state.session) return;
+
+ // Add user message immediately
+ const userMessage: Message = {
+ id: `temp-${Date.now()}`,
+ 'session-id': state.session.id,
+ role: 'user',
+ content: message,
+ 'created-at': new Date().toISOString()
+ };
+
+ update((s) => ({
+ ...s,
+ messages: [...s.messages, userMessage],
+ streamingContent: ''
+ }));
+
+ try {
+ await api.sendMessage(state.session.id, message);
+ } catch (e) {
+ update((s) => ({ ...s, error: (e as Error).message }));
+ }
+ },
+ appendStreamContent(text: string) {
+ update((s) => ({ ...s, streamingContent: s.streamingContent + text }));
+ },
+ finalizeStream() {
+ update((s) => {
+ if (!s.streamingContent || !s.session) return s;
+
+ const assistantMessage: Message = {
+ id: `stream-${Date.now()}`,
+ 'session-id': s.session.id,
+ role: 'assistant',
+ content: s.streamingContent,
+ 'created-at': new Date().toISOString()
+ };
+
+ return {
+ ...s,
+ messages: [...s.messages, assistantMessage],
+ streamingContent: ''
+ };
+ });
+ },
+ clear() {
+ if (unsubscribeWs) {
+ unsubscribeWs();
+ unsubscribeWs = null;
+ }
+ set({
+ session: null,
+ messages: [],
+ streamingContent: '',
+ loading: false,
+ error: null
+ });
+ }
+ };
+
+ function get(): ActiveSessionState {
+ let state: ActiveSessionState;
+ subscribe((s) => (state = s))();
+ return state!;
+ }
+
+ function handleStreamEvent(event: StreamEvent) {
+ if (event.event === 'content-delta' && event.text) {
+ update((s) => ({ ...s, streamingContent: s.streamingContent + event.text }));
+ } else if (event.event === 'message-stop') {
+ update((s) => {
+ if (!s.streamingContent || !s.session) return s;
+
+ const assistantMessage: Message = {
+ id: `stream-${Date.now()}`,
+ 'session-id': s.session.id,
+ role: 'assistant',
+ content: s.streamingContent,
+ 'created-at': new Date().toISOString()
+ };
+
+ return {
+ ...s,
+ messages: [...s.messages, assistantMessage],
+ streamingContent: ''
+ };
+ });
+ } else if (event.event === 'error') {
+ update((s) => ({ ...s, error: event.message || 'Stream error' }));
+ }
+ }
+}
+
+export const sessions = createSessionsStore();
+export const activeSession = createActiveSessionStore();
+
+// Derived stores
+export const sortedSessions: Readable = derived(sessions, ($sessions) =>
+ [...$sessions.sessions].sort((a, b) => {
+ const aDate = a['updated-at'] || a.updatedAt || a['created-at'] || a.createdAt || '';
+ const bDate = b['updated-at'] || b.updatedAt || b['created-at'] || b.createdAt || '';
+ return bDate.localeCompare(aDate);
+ })
+);
+
+export const runningSessions: Readable = derived(sessions, ($sessions) =>
+ $sessions.sessions.filter((s) => s.status === 'running')
+);
diff --git a/client/src/routes/+layout.svelte b/client/src/routes/+layout.svelte
new file mode 100644
index 0000000..38bd38f
--- /dev/null
+++ b/client/src/routes/+layout.svelte
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/client/src/routes/+layout.ts b/client/src/routes/+layout.ts
new file mode 100644
index 0000000..ceccaaf
--- /dev/null
+++ b/client/src/routes/+layout.ts
@@ -0,0 +1,2 @@
+export const prerender = true;
+export const ssr = false;
diff --git a/client/src/routes/+page.svelte b/client/src/routes/+page.svelte
new file mode 100644
index 0000000..7c741f6
--- /dev/null
+++ b/client/src/routes/+page.svelte
@@ -0,0 +1,245 @@
+
+
+
+ Spiceflow
+
+
+
+
+
+
+
+ {#if $sessions.error}
+
+ {$sessions.error}
+
+ {/if}
+
+ {#if $sessions.loading && $sortedSessions.length === 0}
+
+
+
+
Loading sessions...
+
+
+ {:else if $sortedSessions.length === 0}
+
+
+
+
No sessions yet
+
+ Click "Discover" to find existing Claude Code or OpenCode sessions on your machine.
+
+
+
+ {:else}
+
+ {#each $sortedSessions as session (session.id)}
+
+ {/each}
+
+ {/if}
+
+
+
+{#if showDiscovery}
+ (showDiscovery = false)}
+ on:keydown={(e) => e.key === 'Escape' && (showDiscovery = false)}
+ role="dialog"
+ tabindex="-1"
+ >
+
+
+
Discovered Sessions
+
+
+
+
+ {#if discoveredSessions.length === 0}
+
No new sessions found.
+ {:else}
+
+ {#each discoveredSessions as session}
+
+
+
+
+ {session.provider}
+
+
+
+ {session.title || session['external-id'].slice(0, 8)}
+
+ {#if session['working-dir']}
+
+ {session['working-dir']}
+
+ {/if}
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+{/if}
diff --git a/client/src/routes/session/[id]/+page.svelte b/client/src/routes/session/[id]/+page.svelte
new file mode 100644
index 0000000..577185a
--- /dev/null
+++ b/client/src/routes/session/[id]/+page.svelte
@@ -0,0 +1,116 @@
+
+
+
+ {session?.title || `Session ${shortId}`} - Spiceflow
+
+
+
+
+
+
+{#if $activeSession.error}
+
+
+
{$activeSession.error}
+
+
+
+{:else if $activeSession.loading}
+
+{:else}
+
+
+
+{/if}
diff --git a/client/static/apple-touch-icon.svg b/client/static/apple-touch-icon.svg
new file mode 100644
index 0000000..a36910b
--- /dev/null
+++ b/client/static/apple-touch-icon.svg
@@ -0,0 +1,12 @@
+
diff --git a/client/static/favicon.ico b/client/static/favicon.ico
new file mode 120000
index 0000000..423f809
--- /dev/null
+++ b/client/static/favicon.ico
@@ -0,0 +1 @@
+favicon.svg
\ No newline at end of file
diff --git a/client/static/favicon.svg b/client/static/favicon.svg
new file mode 100644
index 0000000..32a3fc1
--- /dev/null
+++ b/client/static/favicon.svg
@@ -0,0 +1,12 @@
+
diff --git a/client/static/pwa-192x192.svg b/client/static/pwa-192x192.svg
new file mode 100644
index 0000000..a36910b
--- /dev/null
+++ b/client/static/pwa-192x192.svg
@@ -0,0 +1,12 @@
+
diff --git a/client/static/pwa-512x512.svg b/client/static/pwa-512x512.svg
new file mode 100644
index 0000000..a36910b
--- /dev/null
+++ b/client/static/pwa-512x512.svg
@@ -0,0 +1,12 @@
+
diff --git a/client/svelte.config.js b/client/svelte.config.js
new file mode 100644
index 0000000..930e539
--- /dev/null
+++ b/client/svelte.config.js
@@ -0,0 +1,21 @@
+import adapter from '@sveltejs/adapter-static';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: vitePreprocess(),
+ kit: {
+ adapter: adapter({
+ pages: 'build',
+ assets: 'build',
+ fallback: 'index.html',
+ precompress: false,
+ strict: true
+ }),
+ paths: {
+ base: ''
+ }
+ }
+};
+
+export default config;
diff --git a/client/tailwind.config.js b/client/tailwind.config.js
new file mode 100644
index 0000000..f13e987
--- /dev/null
+++ b/client/tailwind.config.js
@@ -0,0 +1,27 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ['./src/**/*.{html,js,svelte,ts}'],
+ theme: {
+ extend: {
+ colors: {
+ spice: {
+ 50: '#fff7ed',
+ 100: '#ffedd5',
+ 200: '#fed7aa',
+ 300: '#fdba74',
+ 400: '#fb923c',
+ 500: '#f97316',
+ 600: '#ea580c',
+ 700: '#c2410c',
+ 800: '#9a3412',
+ 900: '#7c2d12',
+ 950: '#431407'
+ }
+ },
+ fontFamily: {
+ mono: ['JetBrains Mono', 'Fira Code', 'monospace']
+ }
+ }
+ },
+ plugins: []
+};
diff --git a/client/tsconfig.json b/client/tsconfig.json
new file mode 100644
index 0000000..a8f10c8
--- /dev/null
+++ b/client/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+}
diff --git a/client/vite.config.ts b/client/vite.config.ts
new file mode 100644
index 0000000..d60e0a3
--- /dev/null
+++ b/client/vite.config.ts
@@ -0,0 +1,70 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vite';
+import { VitePWA } from 'vite-plugin-pwa';
+
+export default defineConfig({
+ plugins: [
+ sveltekit(),
+ VitePWA({
+ registerType: 'autoUpdate',
+ includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
+ manifest: {
+ name: 'Spiceflow',
+ short_name: 'Spiceflow',
+ description: 'AI Session Orchestration - The spice must flow',
+ theme_color: '#f97316',
+ background_color: '#18181b',
+ display: 'standalone',
+ orientation: 'portrait',
+ scope: '/',
+ start_url: '/',
+ icons: [
+ {
+ src: 'pwa-192x192.png',
+ sizes: '192x192',
+ type: 'image/png'
+ },
+ {
+ src: 'pwa-512x512.png',
+ sizes: '512x512',
+ type: 'image/png'
+ },
+ {
+ src: 'pwa-512x512.png',
+ sizes: '512x512',
+ type: 'image/png',
+ purpose: 'any maskable'
+ }
+ ]
+ },
+ workbox: {
+ globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
+ runtimeCaching: [
+ {
+ urlPattern: /^https:\/\/.*\/api\/.*/i,
+ handler: 'NetworkFirst',
+ options: {
+ cacheName: 'api-cache',
+ expiration: {
+ maxEntries: 100,
+ maxAgeSeconds: 60 * 5 // 5 minutes
+ }
+ }
+ }
+ ]
+ },
+ devOptions: {
+ enabled: true
+ }
+ })
+ ],
+ server: {
+ proxy: {
+ '/api': {
+ target: 'http://localhost:3000',
+ changeOrigin: true,
+ ws: true
+ }
+ }
+ }
+});
diff --git a/server/deps.edn b/server/deps.edn
new file mode 100644
index 0000000..49ec183
--- /dev/null
+++ b/server/deps.edn
@@ -0,0 +1,36 @@
+{:paths ["src" "resources"]
+ :deps {org.clojure/clojure {:mvn/version "1.11.1"}
+
+ ;; Web server
+ ring/ring-core {:mvn/version "1.10.0"}
+ ring/ring-jetty-adapter {:mvn/version "1.10.0"}
+ ring/ring-json {:mvn/version "0.5.1"}
+ ring-cors/ring-cors {:mvn/version "0.1.13"}
+
+ ;; Routing
+ metosin/reitit {:mvn/version "0.7.0-alpha7"}
+
+ ;; WebSocket
+ info.sunng/ring-jetty9-adapter {:mvn/version "0.30.0"}
+
+ ;; Database
+ com.github.seancorfield/next.jdbc {:mvn/version "1.3.894"}
+ org.xerial/sqlite-jdbc {:mvn/version "3.44.1.0"}
+
+ ;; JSON
+ metosin/jsonista {:mvn/version "0.3.8"}
+
+ ;; Utilities
+ aero/aero {:mvn/version "1.1.6"}
+ mount/mount {:mvn/version "0.1.18"}
+ org.clojure/tools.logging {:mvn/version "1.2.4"}
+ ch.qos.logback/logback-classic {:mvn/version "1.4.11"}}
+
+ :aliases
+ {:run {:main-opts ["-m" "spiceflow.core"]}
+ :test {:extra-paths ["test"]
+ :extra-deps {lambdaisland/kaocha {:mvn/version "1.87.1366"}}
+ :main-opts ["-m" "kaocha.runner"]}
+ :repl {:main-opts ["-m" "nrepl.cmdline" "-i"]
+ :extra-deps {nrepl/nrepl {:mvn/version "1.1.0"}
+ cider/cider-nrepl {:mvn/version "0.44.0"}}}}}
diff --git a/server/resources/config.edn b/server/resources/config.edn
new file mode 100644
index 0000000..56b1a38
--- /dev/null
+++ b/server/resources/config.edn
@@ -0,0 +1,10 @@
+{: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"]}
+
+ :claude {:sessions-dir #or [#env CLAUDE_SESSIONS_DIR
+ #join [#env HOME "/.claude/projects"]]}
+
+ :opencode {:command #or [#env OPENCODE_CMD "opencode"]}}
diff --git a/server/resources/logback.xml b/server/resources/logback.xml
new file mode 100644
index 0000000..ab0dad2
--- /dev/null
+++ b/server/resources/logback.xml
@@ -0,0 +1,15 @@
+
+
+
+
+ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
+
+
+
+
+
+
+
+
+
+
diff --git a/server/src/spiceflow/adapters/claude.clj b/server/src/spiceflow/adapters/claude.clj
new file mode 100644
index 0000000..cef3bf9
--- /dev/null
+++ b/server/src/spiceflow/adapters/claude.clj
@@ -0,0 +1,171 @@
+(ns spiceflow.adapters.claude
+ "Adapter for Claude Code CLI"
+ (:require [spiceflow.adapters.protocol :as proto]
+ [clojure.java.io :as io]
+ [clojure.string :as str]
+ [jsonista.core :as json]
+ [clojure.tools.logging :as log])
+ (:import [java.io BufferedReader InputStreamReader BufferedWriter OutputStreamWriter]
+ [java.nio.file Files Paths]
+ [java.net URLDecoder]
+ [java.nio.charset StandardCharsets]))
+
+(def ^:private mapper (json/object-mapper {:decode-key-fn keyword}))
+
+(defn- decode-path
+ "Decode a URL-encoded path segment"
+ [encoded]
+ (-> encoded
+ (str/replace "%" "%25") ; Handle already-encoded % signs
+ (str/replace "%25" "%")
+ (URLDecoder/decode "UTF-8")))
+
+(defn- encoded-path->real-path
+ "Convert Claude's encoded path format back to real path.
+ e.g., '-home-user-project' -> '/home/user/project'"
+ [encoded]
+ (-> encoded
+ (str/replace #"^-" "/")
+ (str/replace "-" "/")))
+
+(defn- discover-project-sessions
+ "Discover sessions from a single project directory"
+ [project-dir]
+ (let [project-path (encoded-path->real-path (.getName project-dir))]
+ (->> (.listFiles project-dir)
+ (filter #(str/ends-with? (.getName %) ".jsonl"))
+ (map (fn [session-file]
+ (let [session-id (str/replace (.getName session-file) ".jsonl" "")]
+ {:external-id session-id
+ :provider :claude
+ :working-dir project-path
+ :title (str "Session " (subs session-id 0 8) "...")
+ :file-path (.getAbsolutePath session-file)}))))))
+
+(defn- parse-jsonl-message
+ "Parse a JSONL message from Claude Code"
+ [line]
+ (try
+ (let [data (json/read-value line mapper)]
+ (case (:type data)
+ "user" {:role :user
+ :content (get-in data [:message :content])
+ :timestamp (:timestamp data)
+ :uuid (:uuid data)}
+ "assistant" {:role :assistant
+ :content (get-in data [:message :content])
+ :timestamp (:timestamp data)
+ :uuid (:uuid data)
+ :metadata {:model (get-in data [:message :model])
+ :stop-reason (get-in data [:message :stopReason])}}
+ ;; Stream events from --output-format stream-json
+ "content_block_start" {:event :content-start
+ :index (:index data)
+ :content-type (get-in data [:content_block :type])}
+ "content_block_delta" {:event :content-delta
+ :index (:index data)
+ :text (get-in data [:delta :text])}
+ "content_block_stop" {:event :content-stop
+ :index (:index data)}
+ "message_start" {:event :message-start
+ :message-id (get-in data [:message :id])}
+ "message_delta" {:event :message-delta
+ :stop-reason (get-in data [:delta :stop_reason])}
+ "message_stop" {:event :message-stop}
+ "result" {:event :result
+ :content (get-in data [:result :assistant :content])
+ :cost (:cost data)
+ :session-id (:session_id data)}
+ ;; Unknown type
+ {:raw data}))
+ (catch Exception e
+ (log/debug "Failed to parse JSONL line:" line (.getMessage e))
+ nil)))
+
+(defn- read-session-messages
+ "Read messages from a session JSONL file"
+ [file-path]
+ (try
+ (with-open [reader (io/reader file-path)]
+ (->> (line-seq reader)
+ (keep parse-jsonl-message)
+ (filter #(contains? #{:user :assistant} (:role %)))
+ vec))
+ (catch Exception e
+ (log/warn "Failed to read session file:" file-path (.getMessage e))
+ [])))
+
+(defrecord ClaudeAdapter [sessions-dir]
+ proto/AgentAdapter
+
+ (provider-name [_] :claude)
+
+ (discover-sessions [_]
+ (let [base-dir (io/file sessions-dir)]
+ (if (.exists base-dir)
+ (->> (.listFiles base-dir)
+ (filter #(.isDirectory %))
+ (mapcat discover-project-sessions)
+ vec)
+ [])))
+
+ (spawn-session [_ session-id opts]
+ (let [{:keys [working-dir model permission-mode]} opts
+ args (cond-> ["claude"
+ "--resume" session-id
+ "--output-format" "stream-json"
+ "--input-format" "stream-json"
+ "--print"]
+ model (conj "--model" model)
+ permission-mode (conj "--permission-mode" permission-mode))
+ pb (ProcessBuilder. args)]
+ (when working-dir
+ (.directory pb (io/file working-dir)))
+ (.redirectErrorStream pb false)
+ (let [process (.start pb)]
+ {:process process
+ :stdin (BufferedWriter. (OutputStreamWriter. (.getOutputStream process) StandardCharsets/UTF_8))
+ :stdout (BufferedReader. (InputStreamReader. (.getInputStream process) StandardCharsets/UTF_8))
+ :stderr (BufferedReader. (InputStreamReader. (.getErrorStream process) StandardCharsets/UTF_8))})))
+
+ (send-message [_ {:keys [stdin]} message]
+ (try
+ (let [json-msg (json/write-value-as-string {:type "user" :content message})]
+ (.write stdin json-msg)
+ (.newLine stdin)
+ (.flush stdin)
+ true)
+ (catch Exception e
+ (log/error "Failed to send message:" (.getMessage e))
+ false)))
+
+ (read-stream [this {:keys [stdout]} callback]
+ (try
+ (loop []
+ (when-let [line (.readLine stdout)]
+ (when-let [parsed (proto/parse-output this line)]
+ (callback parsed))
+ (recur)))
+ (catch Exception e
+ (log/debug "Stream ended:" (.getMessage e)))))
+
+ (kill-process [_ {:keys [process]}]
+ (when process
+ (.destroyForcibly process)))
+
+ (parse-output [_ line]
+ (when (and line (not (str/blank? line)))
+ (parse-jsonl-message line))))
+
+(defn create-adapter
+ "Create a Claude Code adapter"
+ ([]
+ (create-adapter (str (System/getProperty "user.home") "/.claude/projects")))
+ ([sessions-dir]
+ (->ClaudeAdapter sessions-dir)))
+
+(defn get-session-messages
+ "Read messages from a discovered session"
+ [session]
+ (when-let [file-path (:file-path session)]
+ (read-session-messages file-path)))
diff --git a/server/src/spiceflow/adapters/opencode.clj b/server/src/spiceflow/adapters/opencode.clj
new file mode 100644
index 0000000..a5aaba8
--- /dev/null
+++ b/server/src/spiceflow/adapters/opencode.clj
@@ -0,0 +1,145 @@
+(ns spiceflow.adapters.opencode
+ "Adapter for OpenCode CLI"
+ (:require [spiceflow.adapters.protocol :as proto]
+ [clojure.java.io :as io]
+ [clojure.java.shell :as shell]
+ [clojure.string :as str]
+ [jsonista.core :as json]
+ [clojure.tools.logging :as log])
+ (:import [java.io BufferedReader InputStreamReader BufferedWriter OutputStreamWriter]
+ [java.nio.charset StandardCharsets]))
+
+(def ^:private mapper (json/object-mapper {:decode-key-fn keyword}))
+
+(defn- run-command
+ "Run an opencode command and return parsed output"
+ [command & args]
+ (let [result (apply shell/sh command args)]
+ (when (zero? (:exit result))
+ (:out result))))
+
+(defn- parse-session-list
+ "Parse output from 'opencode session list'"
+ [output]
+ (try
+ (let [data (json/read-value output mapper)]
+ (->> data
+ (map (fn [session]
+ {:external-id (or (:id session) (:session_id session))
+ :provider :opencode
+ :title (:title session)
+ :working-dir (:working_dir session)
+ :created-at (get-in session [:time :created])
+ :updated-at (get-in session [:time :updated])}))))
+ (catch Exception e
+ (log/debug "Failed to parse session list, trying line-by-line")
+ ;; Fallback: parse line by line if it's not JSON
+ (->> (str/split-lines output)
+ (filter #(str/starts-with? % "ses_"))
+ (map (fn [line]
+ (let [[id title] (str/split line #"\s+" 2)]
+ {:external-id id
+ :provider :opencode
+ :title (or title "Untitled")})))))))
+
+(defn- parse-session-export
+ "Parse output from 'opencode export '"
+ [output]
+ (try
+ (let [data (json/read-value output mapper)]
+ {:info (:info data)
+ :messages (->> (:messages data)
+ (map (fn [msg]
+ {:role (keyword (get-in msg [:info :role] "assistant"))
+ :content (->> (:parts msg)
+ (filter #(= (:type %) "text"))
+ (map :text)
+ (str/join "\n"))
+ :metadata {:parts (:parts msg)}})))})
+ (catch Exception e
+ (log/warn "Failed to parse session export:" (.getMessage e))
+ nil)))
+
+(defn- parse-stream-output
+ "Parse a line of streaming output from OpenCode"
+ [line]
+ (try
+ (when (and line (not (str/blank? line)))
+ (if (str/starts-with? line "{")
+ (let [data (json/read-value line mapper)]
+ (cond
+ (:content data) {:event :content-delta
+ :text (:content data)}
+ (:done data) {:event :message-stop}
+ (:error data) {:event :error
+ :message (:error data)}
+ :else {:raw data}))
+ ;; Plain text output
+ {:event :content-delta
+ :text line}))
+ (catch Exception e
+ (log/debug "Failed to parse line:" line)
+ {:event :content-delta :text line})))
+
+(defrecord OpenCodeAdapter [command]
+ proto/AgentAdapter
+
+ (provider-name [_] :opencode)
+
+ (discover-sessions [_]
+ (if-let [output (run-command command "session" "list" "--json")]
+ (vec (parse-session-list output))
+ []))
+
+ (spawn-session [_ session-id opts]
+ (let [{:keys [working-dir]} opts
+ args [command "run" "--session" session-id]
+ pb (ProcessBuilder. args)]
+ (when working-dir
+ (.directory pb (io/file working-dir)))
+ (.redirectErrorStream pb false)
+ (let [process (.start pb)]
+ {:process process
+ :stdin (BufferedWriter. (OutputStreamWriter. (.getOutputStream process) StandardCharsets/UTF_8))
+ :stdout (BufferedReader. (InputStreamReader. (.getInputStream process) StandardCharsets/UTF_8))
+ :stderr (BufferedReader. (InputStreamReader. (.getErrorStream process) StandardCharsets/UTF_8))})))
+
+ (send-message [_ {:keys [stdin]} message]
+ (try
+ (.write stdin message)
+ (.newLine stdin)
+ (.flush stdin)
+ true
+ (catch Exception e
+ (log/error "Failed to send message:" (.getMessage e))
+ false)))
+
+ (read-stream [this {:keys [stdout]} callback]
+ (try
+ (loop []
+ (when-let [line (.readLine stdout)]
+ (when-let [parsed (proto/parse-output this line)]
+ (callback parsed))
+ (recur)))
+ (catch Exception e
+ (log/debug "Stream ended:" (.getMessage e)))))
+
+ (kill-process [_ {:keys [process]}]
+ (when process
+ (.destroyForcibly process)))
+
+ (parse-output [_ line]
+ (parse-stream-output line)))
+
+(defn create-adapter
+ "Create an OpenCode adapter"
+ ([]
+ (create-adapter "opencode"))
+ ([command]
+ (->OpenCodeAdapter command)))
+
+(defn export-session
+ "Export a session's full history"
+ [adapter session-id]
+ (when-let [output (run-command (:command adapter) "export" session-id)]
+ (parse-session-export output)))
diff --git a/server/src/spiceflow/adapters/protocol.clj b/server/src/spiceflow/adapters/protocol.clj
new file mode 100644
index 0000000..b2dc81d
--- /dev/null
+++ b/server/src/spiceflow/adapters/protocol.clj
@@ -0,0 +1,38 @@
+(ns spiceflow.adapters.protocol)
+
+(defprotocol AgentAdapter
+ "Protocol for interacting with AI coding assistants (Claude Code, OpenCode, etc.)"
+
+ (provider-name [this]
+ "Return the provider name as a keyword (:claude or :opencode)")
+
+ (discover-sessions [this]
+ "Discover existing sessions from the CLI's storage.
+ Returns a sequence of session maps with :external-id, :title, :working-dir, etc.")
+
+ (spawn-session [this session-id opts]
+ "Spawn a new CLI process for the given session.
+ opts may include :working-dir, :model, :permission-mode
+ Returns a process handle map with :process, :stdin, :stdout, :stderr")
+
+ (send-message [this process-handle message]
+ "Send a message to a running CLI process.
+ Returns true if sent successfully.")
+
+ (read-stream [this process-handle callback]
+ "Read streamed output from the CLI process.
+ callback is called with each parsed message/event.
+ Returns when the process completes or stream ends.")
+
+ (kill-process [this process-handle]
+ "Kill a running CLI process.")
+
+ (parse-output [this line]
+ "Parse a line of CLI output into a structured message.
+ Returns nil if the line should be ignored."))
+
+(defn running?
+ "Check if a process handle represents a running process"
+ [{:keys [process]}]
+ (when process
+ (.isAlive process)))
diff --git a/server/src/spiceflow/api/routes.clj b/server/src/spiceflow/api/routes.clj
new file mode 100644
index 0000000..9db8187
--- /dev/null
+++ b/server/src/spiceflow/api/routes.clj
@@ -0,0 +1,141 @@
+(ns spiceflow.api.routes
+ "REST API routes"
+ (:require [reitit.ring :as ring]
+ [reitit.ring.middleware.parameters :as parameters]
+ [ring.middleware.json :refer [wrap-json-body wrap-json-response]]
+ [ring.middleware.cors :refer [wrap-cors]]
+ [ring.util.response :as response]
+ [spiceflow.db.protocol :as db]
+ [spiceflow.session.manager :as manager]
+ [spiceflow.adapters.claude :as claude]
+ [clojure.tools.logging :as log]))
+
+(defn- json-response
+ "Create a JSON response"
+ [body]
+ (-> (response/response body)
+ (response/content-type "application/json")))
+
+(defn- error-response
+ "Create an error response"
+ [status message]
+ (-> (response/response {:error message})
+ (response/status status)
+ (response/content-type "application/json")))
+
+;; Session handlers
+(defn list-sessions-handler
+ [store]
+ (fn [_request]
+ (json-response (db/get-sessions store))))
+
+(defn get-session-handler
+ [store]
+ (fn [request]
+ (let [id (get-in request [:path-params :id])]
+ (if-let [session (db/get-session store id)]
+ (let [messages (db/get-messages store id)]
+ (json-response (assoc session :messages messages)))
+ (error-response 404 "Session not found")))))
+
+(defn create-session-handler
+ [store]
+ (fn [request]
+ (let [body (:body request)]
+ (if (db/valid-session? body)
+ (let [session (db/save-session store body)]
+ (-> (json-response session)
+ (response/status 201)))
+ (error-response 400 "Invalid session data")))))
+
+(defn delete-session-handler
+ [store]
+ (fn [request]
+ (let [id (get-in request [:path-params :id])]
+ (if (db/get-session store id)
+ (do
+ (manager/stop-session store id)
+ (db/delete-session store id)
+ (response/status (response/response nil) 204))
+ (error-response 404 "Session not found")))))
+
+(defn send-message-handler
+ [store broadcast-fn]
+ (fn [request]
+ (let [id (get-in request [:path-params :id])
+ message (get-in request [:body :message])]
+ (if-let [session (db/get-session store id)]
+ (try
+ ;; Send message and start streaming in a separate thread
+ (manager/send-message-to-session store id message)
+ (future
+ (try
+ (manager/stream-session-response store id
+ (fn [event]
+ (broadcast-fn id event)))
+ (catch Exception e
+ (log/error "Streaming error:" (.getMessage e))
+ (broadcast-fn id {:event :error :message (.getMessage e)}))))
+ (json-response {:status "sent"})
+ (catch Exception e
+ (error-response 500 (.getMessage e))))
+ (error-response 404 "Session not found")))))
+
+;; Discovery handlers
+(defn discover-claude-handler
+ [_store]
+ (fn [_request]
+ (let [adapter (claude/create-adapter)
+ sessions (manager/discover-all-sessions)]
+ (json-response (->> sessions
+ (filter #(= :claude (:provider %)))
+ vec)))))
+
+(defn discover-opencode-handler
+ [_store]
+ (fn [_request]
+ (let [sessions (manager/discover-all-sessions)]
+ (json-response (->> sessions
+ (filter #(= :opencode (:provider %)))
+ vec)))))
+
+(defn import-session-handler
+ [store]
+ (fn [request]
+ (let [body (:body request)]
+ (if (:external-id body)
+ (let [session (manager/import-session store body)]
+ (-> (json-response session)
+ (response/status 201)))
+ (error-response 400 "Missing external-id")))))
+
+;; Health check
+(defn health-handler
+ [_request]
+ (json-response {:status "ok" :service "spiceflow"}))
+
+(defn create-routes
+ "Create API routes with the given store and broadcast function"
+ [store broadcast-fn]
+ [["/api"
+ ["/health" {:get health-handler}]
+ ["/sessions" {:get (list-sessions-handler store)
+ :post (create-session-handler store)}]
+ ["/sessions/:id" {:get (get-session-handler store)
+ :delete (delete-session-handler store)}]
+ ["/sessions/:id/send" {:post (send-message-handler store broadcast-fn)}]
+ ["/discover/claude" {:get (discover-claude-handler store)}]
+ ["/discover/opencode" {:get (discover-opencode-handler store)}]
+ ["/import" {:post (import-session-handler store)}]]])
+
+(defn create-app
+ "Create the Ring application"
+ [store broadcast-fn]
+ (-> (ring/ring-handler
+ (ring/router (create-routes store broadcast-fn))
+ (ring/create-default-handler))
+ (wrap-json-body {:keywords? true})
+ wrap-json-response
+ (wrap-cors :access-control-allow-origin [#".*"]
+ :access-control-allow-methods [:get :post :put :delete :options]
+ :access-control-allow-headers [:content-type :authorization])))
diff --git a/server/src/spiceflow/api/websocket.clj b/server/src/spiceflow/api/websocket.clj
new file mode 100644
index 0000000..39124a6
--- /dev/null
+++ b/server/src/spiceflow/api/websocket.clj
@@ -0,0 +1,108 @@
+(ns spiceflow.api.websocket
+ "WebSocket handlers for real-time updates"
+ (:require [jsonista.core :as json]
+ [clojure.tools.logging :as log])
+ (:import [org.eclipse.jetty.websocket.api Session WebSocketListener]
+ [java.util.concurrent ConcurrentHashMap]))
+
+(def ^:private mapper (json/object-mapper {:encode-key-fn name}))
+
+;; Connected WebSocket sessions: session-id -> #{ws-sessions}
+(defonce ^:private connections (ConcurrentHashMap.))
+
+;; All connected WebSocket sessions for broadcast
+(defonce ^:private all-connections (ConcurrentHashMap/newKeySet))
+
+(defn- send-to-ws
+ "Send a message to a WebSocket session"
+ [^Session ws-session message]
+ (try
+ (when (.isOpen ws-session)
+ (let [json-str (json/write-value-as-string message mapper)]
+ (.sendString (.getRemote ws-session) json-str)))
+ (catch Exception e
+ (log/debug "Failed to send to WebSocket:" (.getMessage e)))))
+
+(defn broadcast-to-session
+ "Broadcast an event to all WebSocket connections subscribed to a session"
+ [session-id event]
+ (when-let [subscribers (.get connections session-id)]
+ (let [message (assoc event :session-id session-id)]
+ (doseq [ws-session subscribers]
+ (send-to-ws ws-session message)))))
+
+(defn broadcast-all
+ "Broadcast an event to all connected WebSocket sessions"
+ [event]
+ (doseq [ws-session all-connections]
+ (send-to-ws ws-session event)))
+
+(defn- subscribe-to-session
+ "Subscribe a WebSocket session to updates for a session"
+ [ws-session session-id]
+ (.compute connections session-id
+ (fn [_k existing]
+ (let [subscribers (or existing (ConcurrentHashMap/newKeySet))]
+ (.add subscribers ws-session)
+ subscribers))))
+
+(defn- unsubscribe-from-session
+ "Unsubscribe a WebSocket session from a session"
+ [ws-session session-id]
+ (when-let [subscribers (.get connections session-id)]
+ (.remove subscribers ws-session)
+ (when (.isEmpty subscribers)
+ (.remove connections session-id))))
+
+(defn- unsubscribe-from-all
+ "Unsubscribe a WebSocket session from all sessions"
+ [ws-session]
+ (doseq [[session-id _] connections]
+ (unsubscribe-from-session ws-session session-id)))
+
+(defn- handle-message
+ "Handle an incoming WebSocket message"
+ [ws-session message]
+ (try
+ (let [data (json/read-value message mapper)]
+ (case (:type data)
+ "subscribe" (do
+ (subscribe-to-session ws-session (:session-id data))
+ (send-to-ws ws-session {:type "subscribed"
+ :session-id (:session-id data)}))
+ "unsubscribe" (do
+ (unsubscribe-from-session ws-session (:session-id data))
+ (send-to-ws ws-session {:type "unsubscribed"
+ :session-id (:session-id data)}))
+ "ping" (send-to-ws ws-session {:type "pong"})
+ (log/debug "Unknown WebSocket message type:" (:type data))))
+ (catch Exception e
+ (log/warn "Failed to handle WebSocket message:" (.getMessage e)))))
+
+(defn create-ws-listener
+ "Create a WebSocket listener"
+ []
+ (reify WebSocketListener
+ (onWebSocketConnect [_ session]
+ (log/debug "WebSocket connected")
+ (.add all-connections session)
+ (send-to-ws session {:type "connected"}))
+
+ (onWebSocketText [_ session message]
+ (handle-message session message))
+
+ (onWebSocketBinary [_ _session _payload _offset _len]
+ (log/debug "Binary WebSocket message ignored"))
+
+ (onWebSocketClose [_ session status-code reason]
+ (log/debug "WebSocket closed:" status-code reason)
+ (.remove all-connections session)
+ (unsubscribe-from-all session))
+
+ (onWebSocketError [_ _session cause]
+ (log/warn "WebSocket error:" (.getMessage cause)))))
+
+(defn ws-handler
+ "Ring handler for WebSocket upgrade"
+ [_request]
+ {:ring.websocket/listener (create-ws-listener)})
diff --git a/server/src/spiceflow/config.clj b/server/src/spiceflow/config.clj
new file mode 100644
index 0000000..1de497c
--- /dev/null
+++ b/server/src/spiceflow/config.clj
@@ -0,0 +1,28 @@
+(ns spiceflow.config
+ (:require [aero.core :as aero]
+ [clojure.java.io :as io]
+ [mount.core :refer [defstate]]))
+
+(defn load-config
+ "Load configuration from config.edn or environment"
+ ([]
+ (load-config :default))
+ ([profile]
+ (let [config-file (io/resource "config.edn")]
+ (if config-file
+ (aero/read-config config-file {:profile profile})
+ ;; Default config if no file exists
+ {:server {:port 3000
+ :host "0.0.0.0"}
+ :database {:type :sqlite
+ :dbname "spiceflow.db"}
+ :claude {:sessions-dir (str (System/getProperty "user.home") "/.claude/projects")}
+ :opencode {:command "opencode"}}))))
+
+(defstate config
+ :start (load-config (keyword (or (System/getenv "SPICEFLOW_ENV") "default"))))
+
+(defn get-in-config
+ "Get a nested value from config"
+ [ks]
+ (get-in config ks))
diff --git a/server/src/spiceflow/core.clj b/server/src/spiceflow/core.clj
new file mode 100644
index 0000000..6bf7d06
--- /dev/null
+++ b/server/src/spiceflow/core.clj
@@ -0,0 +1,56 @@
+(ns spiceflow.core
+ "Main entry point for Spiceflow server"
+ (:require [ring.adapter.jetty9 :as jetty]
+ [spiceflow.config :as config]
+ [spiceflow.db.sqlite :as sqlite]
+ [spiceflow.api.routes :as routes]
+ [spiceflow.api.websocket :as ws]
+ [spiceflow.session.manager :as manager]
+ [mount.core :as mount :refer [defstate]]
+ [clojure.tools.logging :as log])
+ (:gen-class))
+
+;; Database store
+(defstate store
+ :start (do
+ (log/info "Initializing database...")
+ (let [db-path (get-in config/config [:database :dbname] "spiceflow.db")]
+ (sqlite/create-store db-path)))
+ :stop nil)
+
+;; HTTP Server
+(defstate server
+ :start (let [port (get-in config/config [:server :port] 3000)
+ host (get-in config/config [:server :host] "0.0.0.0")
+ app (routes/create-app store ws/broadcast-to-session)]
+ (log/info "Starting Spiceflow server on" (str host ":" port))
+ (jetty/run-jetty app
+ {:port port
+ :host host
+ :join? false
+ :websockets {"/api/ws" ws/ws-handler}}))
+ :stop (do
+ (log/info "Stopping Spiceflow server...")
+ (manager/cleanup-all store)
+ (.stop server)))
+
+(defn -main
+ "Main entry point"
+ [& _args]
+ (log/info "Starting Spiceflow - The spice must flow!")
+ (mount/start)
+ (log/info "Spiceflow is ready.")
+ ;; Keep the main thread alive
+ @(promise))
+
+(comment
+ ;; Development helpers
+ (mount/start)
+ (mount/stop)
+
+ ;; Test database
+ (require '[spiceflow.db.protocol :as db])
+ (db/get-sessions store)
+
+ ;; Test discovery
+ (manager/discover-all-sessions))
diff --git a/server/src/spiceflow/db/memory.clj b/server/src/spiceflow/db/memory.clj
new file mode 100644
index 0000000..00e8ad7
--- /dev/null
+++ b/server/src/spiceflow/db/memory.clj
@@ -0,0 +1,79 @@
+(ns spiceflow.db.memory
+ "In-memory implementation of DataStore for testing"
+ (:require [spiceflow.db.protocol :as proto])
+ (:import [java.util UUID]
+ [java.time Instant]))
+
+(defn- generate-id []
+ (str (UUID/randomUUID)))
+
+(defn- now-iso []
+ (.toString (Instant/now)))
+
+(defrecord MemoryStore [sessions messages]
+ proto/DataStore
+
+ (get-sessions [_]
+ (->> @sessions
+ vals
+ (sort-by :updated-at)
+ reverse
+ vec))
+
+ (get-session [_ id]
+ (get @sessions id))
+
+ (save-session [this session]
+ (let [id (or (:id session) (generate-id))
+ now (now-iso)
+ new-session (assoc session
+ :id id
+ :status (or (:status session) :idle)
+ :created-at now
+ :updated-at now)]
+ (swap! sessions assoc id new-session)
+ new-session))
+
+ (update-session [_ id data]
+ (let [now (now-iso)]
+ (swap! sessions update id merge data {:updated-at now})
+ (get @sessions id)))
+
+ (delete-session [_ id]
+ (swap! messages (fn [msgs]
+ (into {} (remove #(= (:session-id (val %)) id) msgs))))
+ (swap! sessions dissoc id)
+ nil)
+
+ (get-messages [_ session-id]
+ (->> @messages
+ vals
+ (filter #(= (:session-id %) session-id))
+ (sort-by :created-at)
+ vec))
+
+ (save-message [this message]
+ (let [id (or (:id message) (generate-id))
+ now (now-iso)
+ new-message (assoc message
+ :id id
+ :created-at now)]
+ (swap! messages assoc id new-message)
+ ;; Update session's updated-at
+ (when-let [session-id (:session-id message)]
+ (swap! sessions update session-id assoc :updated-at now))
+ new-message))
+
+ (get-message [_ id]
+ (get @messages id)))
+
+(defn create-store
+ "Create an in-memory store for testing"
+ []
+ (->MemoryStore (atom {}) (atom {})))
+
+(defn clear-store!
+ "Clear all data from an in-memory store"
+ [store]
+ (reset! (:sessions store) {})
+ (reset! (:messages store) {}))
diff --git a/server/src/spiceflow/db/protocol.clj b/server/src/spiceflow/db/protocol.clj
new file mode 100644
index 0000000..dfa6043
--- /dev/null
+++ b/server/src/spiceflow/db/protocol.clj
@@ -0,0 +1,38 @@
+(ns spiceflow.db.protocol)
+
+(defprotocol DataStore
+ "Protocol for database operations. Implementations can be SQLite, in-memory, etc."
+
+ ;; Session operations
+ (get-sessions [this]
+ "Get all sessions")
+ (get-session [this id]
+ "Get a single session by ID")
+ (save-session [this session]
+ "Save a new session. Returns the saved session with ID.")
+ (update-session [this id data]
+ "Update session fields. Returns updated session.")
+ (delete-session [this id]
+ "Delete a session and its messages")
+
+ ;; Message operations
+ (get-messages [this session-id]
+ "Get all messages for a session, ordered by created_at")
+ (save-message [this message]
+ "Save a new message. Returns the saved message with ID.")
+ (get-message [this id]
+ "Get a single message by ID"))
+
+(defn valid-session?
+ "Validate session data has required fields"
+ [{:keys [provider]}]
+ (and provider
+ (contains? #{:claude :opencode "claude" "opencode"} provider)))
+
+(defn valid-message?
+ "Validate message data has required fields"
+ [{:keys [session-id role content]}]
+ (and session-id
+ role
+ (contains? #{:user :assistant :system "user" "assistant" "system"} role)
+ content))
diff --git a/server/src/spiceflow/db/sqlite.clj b/server/src/spiceflow/db/sqlite.clj
new file mode 100644
index 0000000..f00b1fb
--- /dev/null
+++ b/server/src/spiceflow/db/sqlite.clj
@@ -0,0 +1,194 @@
+(ns spiceflow.db.sqlite
+ (:require [next.jdbc :as jdbc]
+ [next.jdbc.result-set :as rs]
+ [next.jdbc.sql :as sql]
+ [spiceflow.db.protocol :as proto]
+ [jsonista.core :as json]
+ [clojure.string :as str])
+ (:import [java.util UUID]
+ [java.time Instant]))
+
+(def ^:private mapper (json/object-mapper {:decode-key-fn keyword}))
+
+(defn- generate-id []
+ (str (UUID/randomUUID)))
+
+(defn- now-iso []
+ (.toString (Instant/now)))
+
+(defn- row->session
+ "Convert a database row to a session map"
+ [row]
+ (when row
+ {:id (:sessions/id row)
+ :provider (keyword (:sessions/provider row))
+ :external-id (:sessions/external_id row)
+ :title (:sessions/title row)
+ :working-dir (:sessions/working_dir row)
+ :status (keyword (or (:sessions/status row) "idle"))
+ :created-at (:sessions/created_at row)
+ :updated-at (:sessions/updated_at row)}))
+
+(defn- row->message
+ "Convert a database row to a message map"
+ [row]
+ (when row
+ {:id (:messages/id row)
+ :session-id (:messages/session_id row)
+ :role (keyword (:messages/role row))
+ :content (:messages/content row)
+ :metadata (when-let [m (:messages/metadata row)]
+ (json/read-value m mapper))
+ :created-at (:messages/created_at row)}))
+
+(defn- session->row
+ "Convert a session map to database columns"
+ [{:keys [id provider external-id title working-dir status]}]
+ (cond-> {}
+ id (assoc :id id)
+ provider (assoc :provider (name provider))
+ external-id (assoc :external_id external-id)
+ title (assoc :title title)
+ working-dir (assoc :working_dir working-dir)
+ status (assoc :status (name status))))
+
+(defn- message->row
+ "Convert a message map to database columns"
+ [{:keys [id session-id role content metadata]}]
+ (cond-> {}
+ id (assoc :id id)
+ session-id (assoc :session_id session-id)
+ role (assoc :role (name role))
+ content (assoc :content content)
+ metadata (assoc :metadata (json/write-value-as-string metadata))))
+
+(defrecord SQLiteStore [datasource]
+ proto/DataStore
+
+ (get-sessions [_]
+ (let [rows (jdbc/execute! datasource
+ ["SELECT * FROM sessions ORDER BY updated_at DESC"]
+ {:builder-fn rs/as-unqualified-kebab-maps})]
+ (mapv (fn [row]
+ {:id (:id row)
+ :provider (keyword (:provider row))
+ :external-id (:external-id row)
+ :title (:title row)
+ :working-dir (:working-dir row)
+ :status (keyword (or (:status row) "idle"))
+ :created-at (:created-at row)
+ :updated-at (:updated-at row)})
+ rows)))
+
+ (get-session [_ id]
+ (let [row (jdbc/execute-one! datasource
+ ["SELECT * FROM sessions WHERE id = ?" id]
+ {:builder-fn rs/as-unqualified-kebab-maps})]
+ (when row
+ {:id (:id row)
+ :provider (keyword (:provider row))
+ :external-id (:external-id row)
+ :title (:title row)
+ :working-dir (:working-dir row)
+ :status (keyword (or (:status row) "idle"))
+ :created-at (:created-at row)
+ :updated-at (:updated-at row)})))
+
+ (save-session [this session]
+ (let [id (or (:id session) (generate-id))
+ now (now-iso)
+ row (-> (session->row session)
+ (assoc :id id
+ :created_at now
+ :updated_at now))]
+ (sql/insert! datasource :sessions row)
+ (proto/get-session this id)))
+
+ (update-session [this id data]
+ (let [row (-> (session->row data)
+ (assoc :updated_at (now-iso)))]
+ (sql/update! datasource :sessions row {:id id})
+ (proto/get-session this id)))
+
+ (delete-session [_ id]
+ (jdbc/execute! datasource ["DELETE FROM messages WHERE session_id = ?" id])
+ (jdbc/execute! datasource ["DELETE FROM sessions WHERE id = ?" id])
+ nil)
+
+ (get-messages [_ session-id]
+ (let [rows (jdbc/execute! datasource
+ ["SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC"
+ session-id]
+ {:builder-fn rs/as-unqualified-kebab-maps})]
+ (mapv (fn [row]
+ {:id (:id row)
+ :session-id (:session-id row)
+ :role (keyword (:role row))
+ :content (:content row)
+ :metadata (when-let [m (:metadata row)]
+ (json/read-value m mapper))
+ :created-at (:created-at row)})
+ rows)))
+
+ (save-message [this message]
+ (let [id (or (:id message) (generate-id))
+ now (now-iso)
+ row (-> (message->row message)
+ (assoc :id id
+ :created_at now))]
+ (sql/insert! datasource :messages row)
+ ;; Update session's updated_at
+ (jdbc/execute! datasource
+ ["UPDATE sessions SET updated_at = ? WHERE id = ?"
+ now (:session-id message)])
+ (proto/get-message this id)))
+
+ (get-message [_ id]
+ (let [row (jdbc/execute-one! datasource
+ ["SELECT * FROM messages WHERE id = ?" id]
+ {:builder-fn rs/as-unqualified-kebab-maps})]
+ (when row
+ {:id (:id row)
+ :session-id (:session-id row)
+ :role (keyword (:role row))
+ :content (:content row)
+ :metadata (when-let [m (:metadata row)]
+ (json/read-value m mapper))
+ :created-at (:created-at row)}))))
+
+(def schema
+ "SQLite schema for spiceflow"
+ ["CREATE TABLE IF NOT EXISTS sessions (
+ id TEXT PRIMARY KEY,
+ provider TEXT NOT NULL,
+ external_id TEXT,
+ title TEXT,
+ working_dir TEXT,
+ status TEXT DEFAULT 'idle',
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now'))
+ )"
+ "CREATE TABLE IF NOT EXISTS messages (
+ id TEXT PRIMARY KEY,
+ session_id TEXT REFERENCES sessions(id),
+ role TEXT NOT NULL,
+ content TEXT,
+ metadata TEXT,
+ created_at TEXT DEFAULT (datetime('now'))
+ )"
+ "CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)"
+ "CREATE INDEX IF NOT EXISTS idx_sessions_provider ON sessions(provider)"
+ "CREATE INDEX IF NOT EXISTS idx_sessions_external_id ON sessions(external_id)"])
+
+(defn init-schema!
+ "Initialize database schema"
+ [datasource]
+ (doseq [stmt schema]
+ (jdbc/execute! datasource [stmt])))
+
+(defn create-store
+ "Create a SQLite store with the given database path"
+ [db-path]
+ (let [datasource (jdbc/get-datasource {:dbtype "sqlite" :dbname db-path})]
+ (init-schema! datasource)
+ (->SQLiteStore datasource)))
diff --git a/server/src/spiceflow/session/manager.clj b/server/src/spiceflow/session/manager.clj
new file mode 100644
index 0000000..a2a220e
--- /dev/null
+++ b/server/src/spiceflow/session/manager.clj
@@ -0,0 +1,122 @@
+(ns spiceflow.session.manager
+ "Session lifecycle management"
+ (:require [spiceflow.db.protocol :as db]
+ [spiceflow.adapters.protocol :as adapter]
+ [spiceflow.adapters.claude :as claude]
+ [spiceflow.adapters.opencode :as opencode]
+ [clojure.tools.logging :as log])
+ (:import [java.util.concurrent ConcurrentHashMap]))
+
+;; Active process handles for running sessions
+(defonce ^:private active-processes (ConcurrentHashMap.))
+
+(defn get-adapter
+ "Get the appropriate adapter for a provider"
+ [provider]
+ (case (keyword provider)
+ :claude (claude/create-adapter)
+ :opencode (opencode/create-adapter)
+ (throw (ex-info "Unknown provider" {:provider provider}))))
+
+(defn discover-all-sessions
+ "Discover sessions from all configured providers"
+ []
+ (let [claude-sessions (adapter/discover-sessions (claude/create-adapter))
+ opencode-sessions (adapter/discover-sessions (opencode/create-adapter))]
+ (concat claude-sessions opencode-sessions)))
+
+(defn import-session
+ "Import a discovered session into the database"
+ [store session]
+ (db/save-session store session))
+
+(defn get-active-process
+ "Get the active process handle for a session"
+ [session-id]
+ (.get active-processes session-id))
+
+(defn session-running?
+ "Check if a session has an active process"
+ [session-id]
+ (when-let [handle (get-active-process session-id)]
+ (adapter/running? handle)))
+
+(defn start-session
+ "Start a CLI process for a session"
+ [store session-id]
+ (let [session (db/get-session store session-id)]
+ (when-not session
+ (throw (ex-info "Session not found" {:session-id session-id})))
+ (when (session-running? session-id)
+ (throw (ex-info "Session already running" {:session-id session-id})))
+ (let [adapter (get-adapter (:provider session))
+ handle (adapter/spawn-session adapter
+ (:external-id session)
+ {:working-dir (:working-dir session)})]
+ (.put active-processes session-id handle)
+ (db/update-session store session-id {:status :running})
+ handle)))
+
+(defn stop-session
+ "Stop a running CLI process for a session"
+ [store session-id]
+ (when-let [handle (.remove active-processes session-id)]
+ (let [session (db/get-session store session-id)
+ adapter (get-adapter (:provider session))]
+ (adapter/kill-process adapter handle)
+ (db/update-session store session-id {:status :idle}))))
+
+(defn send-message-to-session
+ "Send a message to a running session"
+ [store session-id message]
+ (let [session (db/get-session store session-id)
+ _ (when-not session
+ (throw (ex-info "Session not found" {:session-id session-id})))
+ handle (get-active-process session-id)
+ ;; Start session if not running
+ handle (or handle (start-session store session-id))
+ adapter (get-adapter (:provider session))]
+ ;; Save user message
+ (db/save-message store {:session-id session-id
+ :role :user
+ :content message})
+ ;; Send to CLI
+ (adapter/send-message adapter handle message)))
+
+(defn stream-session-response
+ "Stream response from a running session, calling callback for each event"
+ [store session-id callback]
+ (let [session (db/get-session store session-id)
+ _ (when-not session
+ (throw (ex-info "Session not found" {:session-id session-id})))
+ handle (get-active-process session-id)
+ _ (when-not handle
+ (throw (ex-info "Session not running" {:session-id session-id})))
+ adapter (get-adapter (:provider session))
+ content-buffer (StringBuilder.)]
+ ;; Read stream and accumulate content
+ (adapter/read-stream adapter handle
+ (fn [event]
+ (callback event)
+ ;; Accumulate text content
+ (when-let [text (:text event)]
+ (.append content-buffer text))
+ ;; On message stop, save the accumulated message
+ (when (= :message-stop (:event event))
+ (let [content (.toString content-buffer)]
+ (when (seq content)
+ (db/save-message store {:session-id session-id
+ :role :assistant
+ :content content}))))))
+ ;; Update session status when stream ends
+ (db/update-session store session-id {:status :idle})
+ (.remove active-processes session-id)))
+
+(defn cleanup-all
+ "Stop all running sessions"
+ [store]
+ (doseq [session-id (keys active-processes)]
+ (try
+ (stop-session store session-id)
+ (catch Exception e
+ (log/warn "Failed to stop session:" session-id (.getMessage e))))))
diff --git a/server/test/spiceflow/adapters_test.clj b/server/test/spiceflow/adapters_test.clj
new file mode 100644
index 0000000..139f226
--- /dev/null
+++ b/server/test/spiceflow/adapters_test.clj
@@ -0,0 +1,80 @@
+(ns spiceflow.adapters-test
+ (:require [clojure.test :refer [deftest testing is]]
+ [spiceflow.adapters.protocol :as proto]
+ [spiceflow.adapters.claude :as claude]
+ [spiceflow.adapters.opencode :as opencode]))
+
+(deftest test-claude-adapter
+ (testing "Provider name"
+ (let [adapter (claude/create-adapter)]
+ (is (= :claude (proto/provider-name adapter)))))
+
+ (testing "Parse JSONL messages"
+ (let [adapter (claude/create-adapter)]
+ ;; User message
+ (let [parsed (proto/parse-output adapter
+ "{\"type\":\"user\",\"message\":{\"content\":\"Hello\"},\"timestamp\":\"2024-01-01T00:00:00Z\"}")]
+ (is (= :user (:role parsed)))
+ (is (= "Hello" (:content parsed))))
+
+ ;; Assistant message
+ (let [parsed (proto/parse-output adapter
+ "{\"type\":\"assistant\",\"message\":{\"content\":\"Hi!\"},\"timestamp\":\"2024-01-01T00:00:00Z\"}")]
+ (is (= :assistant (:role parsed)))
+ (is (= "Hi!" (:content parsed))))
+
+ ;; Content delta (streaming)
+ (let [parsed (proto/parse-output adapter
+ "{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"text\":\"Hello\"}}")]
+ (is (= :content-delta (:event parsed)))
+ (is (= "Hello" (:text parsed))))
+
+ ;; Message stop
+ (let [parsed (proto/parse-output adapter
+ "{\"type\":\"message_stop\"}")]
+ (is (= :message-stop (:event parsed))))
+
+ ;; Empty line
+ (is (nil? (proto/parse-output adapter "")))
+ (is (nil? (proto/parse-output adapter " "))))))
+
+(deftest test-opencode-adapter
+ (testing "Provider name"
+ (let [adapter (opencode/create-adapter)]
+ (is (= :opencode (proto/provider-name adapter)))))
+
+ (testing "Parse stream output"
+ (let [adapter (opencode/create-adapter)]
+ ;; JSON content
+ (let [parsed (proto/parse-output adapter "{\"content\":\"Hello\"}")]
+ (is (= :content-delta (:event parsed)))
+ (is (= "Hello" (:text parsed))))
+
+ ;; Plain text
+ (let [parsed (proto/parse-output adapter "Some output text")]
+ (is (= :content-delta (:event parsed)))
+ (is (= "Some output text" (:text parsed))))
+
+ ;; Done message
+ (let [parsed (proto/parse-output adapter "{\"done\":true}")]
+ (is (= :message-stop (:event parsed)))))))
+
+(deftest test-discover-sessions
+ (testing "Claude adapter discovers sessions (may be empty)"
+ (let [adapter (claude/create-adapter)
+ sessions (proto/discover-sessions adapter)]
+ (is (vector? sessions))
+ (doseq [session sessions]
+ (is (:external-id session))
+ (is (= :claude (:provider session))))))
+
+ (testing "OpenCode adapter discovers sessions (may fail if not installed)"
+ (let [adapter (opencode/create-adapter)]
+ ;; This may fail if opencode is not installed, so we just check
+ ;; that it doesn't throw an unexpected exception
+ (try
+ (let [sessions (proto/discover-sessions adapter)]
+ (is (or (nil? sessions) (vector? sessions))))
+ (catch Exception _
+ ;; OK if opencode is not installed
+ (is true))))))
diff --git a/server/test/spiceflow/db_test.clj b/server/test/spiceflow/db_test.clj
new file mode 100644
index 0000000..da38f49
--- /dev/null
+++ b/server/test/spiceflow/db_test.clj
@@ -0,0 +1,86 @@
+(ns spiceflow.db-test
+ (:require [clojure.test :refer [deftest testing is use-fixtures]]
+ [spiceflow.db.protocol :as db]
+ [spiceflow.db.memory :as memory]
+ [spiceflow.db.sqlite :as sqlite]))
+
+;; Test both implementations
+(def ^:dynamic *store* nil)
+
+(defn memory-fixture [f]
+ (binding [*store* (memory/create-store)]
+ (f)))
+
+(defn sqlite-fixture [f]
+ (let [db-path (str "/tmp/spiceflow-test-" (System/currentTimeMillis) ".db")]
+ (binding [*store* (sqlite/create-store db-path)]
+ (try
+ (f)
+ (finally
+ (clojure.java.io/delete-file db-path true))))))
+
+;; Session tests
+(deftest test-session-crud
+ (testing "Create and retrieve session"
+ (let [session (db/save-session *store* {:provider :claude
+ :external-id "test-123"
+ :title "Test Session"
+ :working-dir "/home/test"})]
+ (is (:id session))
+ (is (= :claude (:provider session)))
+ (is (= "test-123" (:external-id session)))
+ (is (:created-at session))
+
+ ;; Get session
+ (let [retrieved (db/get-session *store* (:id session))]
+ (is (= (:id session) (:id retrieved)))
+ (is (= "Test Session" (:title retrieved))))))
+
+ (testing "List sessions"
+ (let [sessions (db/get-sessions *store*)]
+ (is (>= (count sessions) 1))))
+
+ (testing "Update session"
+ (let [session (first (db/get-sessions *store*))
+ updated (db/update-session *store* (:id session) {:title "Updated Title"
+ :status :running})]
+ (is (= "Updated Title" (:title updated)))
+ (is (= :running (:status updated)))))
+
+ (testing "Delete session"
+ (let [session (first (db/get-sessions *store*))]
+ (db/delete-session *store* (:id session))
+ (is (nil? (db/get-session *store* (:id session)))))))
+
+;; Message tests
+(deftest test-message-crud
+ (testing "Create and retrieve messages"
+ (let [session (db/save-session *store* {:provider :claude
+ :external-id "msg-test"
+ :title "Message Test"})
+ msg1 (db/save-message *store* {:session-id (:id session)
+ :role :user
+ :content "Hello"})
+ msg2 (db/save-message *store* {:session-id (:id session)
+ :role :assistant
+ :content "Hi there!"
+ :metadata {:model "claude-3"}})]
+ (is (:id msg1))
+ (is (:id msg2))
+
+ ;; Get messages
+ (let [messages (db/get-messages *store* (:id session))]
+ (is (= 2 (count messages)))
+ (is (= :user (:role (first messages))))
+ (is (= :assistant (:role (second messages))))
+ (is (= {:model "claude-3"} (:metadata (second messages)))))
+
+ ;; Get single message
+ (let [retrieved (db/get-message *store* (:id msg1))]
+ (is (= "Hello" (:content retrieved)))))))
+
+;; Run with memory store
+(use-fixtures :each memory-fixture)
+
+;; Uncomment to run with SQLite
+;; (use-fixtures :each sqlite-fixture)
diff --git a/server/tests.edn b/server/tests.edn
new file mode 100644
index 0000000..9e7c718
--- /dev/null
+++ b/server/tests.edn
@@ -0,0 +1,4 @@
+#kaocha/v1
+{:tests [{:id :unit
+ :test-paths ["test"]}]
+ :reporter [kaocha.report/dots]}