From 313ac44337b182b989f698583d72e1cca66c7e4d Mon Sep 17 00:00:00 2001 From: Adam Jeniski Date: Mon, 19 Jan 2026 19:34:58 -0500 Subject: [PATCH] managed sessions only. allow for rename/delete --- CLAUDE.md | 116 +++++++ client/package-lock.json | 45 ++- client/package.json | 4 +- client/src/lib/api.ts | 46 +-- client/src/lib/components/InputBar.svelte | 4 + client/src/lib/components/MessageList.svelte | 24 +- .../lib/components/PermissionRequest.svelte | 78 +++++ client/src/lib/components/SessionCard.svelte | 20 +- client/src/lib/stores/sessions.ts | 86 ++++- client/src/routes/+page.svelte | 197 +++--------- client/src/routes/session/[id]/+page.svelte | 109 ++++++- client/vite.config.ts | 3 + e2e/CLAUDE.md | 55 ++++ e2e/global-setup.ts | 12 + e2e/global-teardown.ts | 12 + e2e/package-lock.json | 302 ++++++++++++++++++ e2e/package.json | 20 ++ e2e/playwright.config.ts | 24 ++ e2e/server-utils.ts | 156 +++++++++ e2e/test-results/.last-run.json | 4 + e2e/tests/basic.spec.ts | 23 ++ e2e/tests/permissions.spec.ts | 122 +++++++ e2e/tests/workflow.spec.ts | 104 ++++++ e2e/tsconfig.json | 15 + script/dev | 63 ++++ script/test | 79 +++++ server/deps.edn | 5 +- server/src/spiceflow/adapters/claude.clj | 33 +- server/src/spiceflow/api/routes.clj | 75 +++-- server/src/spiceflow/api/websocket.clj | 120 ++++--- server/src/spiceflow/core.clj | 32 +- server/src/spiceflow/session/manager.clj | 102 ++++-- 32 files changed, 1759 insertions(+), 331 deletions(-) create mode 100644 CLAUDE.md create mode 100644 client/src/lib/components/PermissionRequest.svelte create mode 100644 e2e/CLAUDE.md create mode 100644 e2e/global-setup.ts create mode 100644 e2e/global-teardown.ts create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/server-utils.ts create mode 100644 e2e/test-results/.last-run.json create mode 100644 e2e/tests/basic.spec.ts create mode 100644 e2e/tests/permissions.spec.ts create mode 100644 e2e/tests/workflow.spec.ts create mode 100644 e2e/tsconfig.json create mode 100755 script/dev create mode 100755 script/test diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..197069f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,116 @@ +# 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 +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 +``` + +### Frontend (SvelteKit) + +```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 +``` + +### E2E Tests (Playwright) + +```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 +``` + +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 + +``` +Claude Code/OpenCode CLI ↔ Spiceflow Server (Clojure) ↔ PWA Client (SvelteKit) + ↓ + SQLite DB +``` + +### Backend (`/server`) + +- **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 + +### Frontend (`/client`) + +- **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 +- **PWA**: vite-plugin-pwa with Workbox service worker + +### Key Protocols + +**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 | + +## 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. Process completes → response saved to database + +## Tech Stack + +- **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 diff --git a/client/package-lock.json b/client/package-lock.json index 4d74930..f03950c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -12,6 +12,7 @@ "@sveltejs/kit": "^2.5.0", "@sveltejs/vite-plugin-svelte": "^3.0.2", "@types/node": "^20.11.0", + "@vitejs/plugin-basic-ssl": "^1.2.0", "autoprefixer": "^10.4.17", "postcss": "^8.4.33", "svelte": "^4.2.9", @@ -2755,6 +2756,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.2.0.tgz", + "integrity": "sha512-mkQnxTkcldAzIsomk1UuLfAu9n+kpQ3JbHcpCp7d2Oo6ITtji8pHS3QToOWjhPFvNQSnhlkAjmGbhv2QvwO/7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -5453,9 +5467,9 @@ } }, "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-5.1.0.tgz", + "integrity": "sha512-G5AJ+IX0aD0dygOE0yFZQ/huFFMSNneyfp0e3/bT05a8OfPC5FUoZRPfGijUdGOJNMewJiwzcHJXFafFzeKFVA==", "dev": true, "funding": [ { @@ -5470,7 +5484,8 @@ "license": "MIT", "peer": true, "dependencies": { - "lilconfig": "^3.1.1" + "lilconfig": "^3.1.1", + "yaml": "^2.4.2" }, "engines": { "node": ">= 18" @@ -5478,8 +5493,7 @@ "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "tsx": "^4.8.1" }, "peerDependenciesMeta": { "jiti": { @@ -5490,9 +5504,6 @@ }, "tsx": { "optional": true - }, - "yaml": { - "optional": true } } }, @@ -7804,6 +7815,22 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/client/package.json b/client/package.json index 9aa1aa9..2ac1682 100644 --- a/client/package.json +++ b/client/package.json @@ -15,6 +15,7 @@ "@sveltejs/kit": "^2.5.0", "@sveltejs/vite-plugin-svelte": "^3.0.2", "@types/node": "^20.11.0", + "@vitejs/plugin-basic-ssl": "^1.2.0", "autoprefixer": "^10.4.17", "postcss": "^8.4.33", "svelte": "^4.2.9", @@ -25,6 +26,5 @@ "vite": "^5.0.12", "vite-plugin-pwa": "^0.19.2", "workbox-window": "^7.0.0" - }, - "dependencies": {} + } } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 8ebf823..337b0cb 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -27,12 +27,15 @@ export interface Message { createdAt?: string; } -export interface DiscoveredSession { - 'external-id': string; - provider: 'claude' | 'opencode'; - title?: string; - 'working-dir'?: string; - 'file-path'?: string; +export interface PermissionDenial { + tool: string; + input: Record; + description: string; +} + +export interface PermissionRequest { + tools: string[]; + denials: PermissionDenial[]; } export interface StreamEvent { @@ -43,6 +46,9 @@ export interface StreamEvent { content?: string; type?: string; message?: string; + cwd?: string; + 'permission-request'?: PermissionRequest; + permissionRequest?: PermissionRequest; } class ApiClient { @@ -93,6 +99,13 @@ class ApiClient { await this.request(`/sessions/${id}`, { method: 'DELETE' }); } + async updateSession(id: string, data: Partial): Promise { + return this.request(`/sessions/${id}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); + } + async sendMessage(sessionId: string, message: string): Promise<{ status: string }> { return this.request<{ status: string }>(`/sessions/${sessionId}/send`, { method: 'POST', @@ -100,19 +113,14 @@ class ApiClient { }); } - // 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', { + async respondToPermission( + sessionId: string, + response: 'accept' | 'deny' | 'steer', + message?: string + ): Promise<{ status: string }> { + return this.request<{ status: string }>(`/sessions/${sessionId}/permission`, { method: 'POST', - body: JSON.stringify(session) + body: JSON.stringify({ response, message }) }); } @@ -134,7 +142,7 @@ export class WebSocketClient { private listeners: Map void>> = new Map(); private globalListeners: Set<(event: StreamEvent) => void> = new Set(); - constructor(url: string = `ws://${window.location.host}/api/ws`) { + constructor(url: string = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/ws`) { this.url = url; } diff --git a/client/src/lib/components/InputBar.svelte b/client/src/lib/components/InputBar.svelte index 766bd87..14900b0 100644 --- a/client/src/lib/components/InputBar.svelte +++ b/client/src/lib/components/InputBar.svelte @@ -30,6 +30,10 @@ textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 150) + 'px'; } + + export function focus() { + textarea?.focus(); + }
diff --git a/client/src/lib/components/MessageList.svelte b/client/src/lib/components/MessageList.svelte index 39e26dc..8a56a82 100644 --- a/client/src/lib/components/MessageList.svelte +++ b/client/src/lib/components/MessageList.svelte @@ -4,6 +4,7 @@ export let messages: Message[] = []; export let streamingContent: string = ''; + export let isThinking: boolean = false; let container: HTMLDivElement; let autoScroll = true; @@ -48,7 +49,7 @@ on:scroll={handleScroll} class="flex-1 overflow-y-auto px-4 py-4 space-y-4" > - {#if messages.length === 0 && !streamingContent} + {#if messages.length === 0 && !streamingContent && !isThinking}
{/each} - {#if streamingContent} + {#if isThinking && !streamingContent} +
+
+ + Assistant + + + + + + +
+
+ {:else if streamingContent}
diff --git a/client/src/lib/components/PermissionRequest.svelte b/client/src/lib/components/PermissionRequest.svelte new file mode 100644 index 0000000..8974ec2 --- /dev/null +++ b/client/src/lib/components/PermissionRequest.svelte @@ -0,0 +1,78 @@ + + +
+
+
+ + + +
+ +
+

Claude needs permission:

+ +
    + {#each permission.denials as denial} +
  • + {denial.tool}: + {denial.description} +
  • + {/each} +
+ +
+ + + +
+
+
+
diff --git a/client/src/lib/components/SessionCard.svelte b/client/src/lib/components/SessionCard.svelte index 6fe78bc..15d03fa 100644 --- a/client/src/lib/components/SessionCard.svelte +++ b/client/src/lib/components/SessionCard.svelte @@ -1,8 +1,17 @@ @@ -55,6 +35,8 @@ Spiceflow + (showNewSessionMenu = false)} /> +
@@ -64,23 +46,46 @@
- + + {#if showNewSessionMenu} +
+ + +
{/if} - +
- -
@@ -190,88 +168,15 @@

No sessions yet

- Click the + button to start a new chat, or "Discover" to find existing Claude Code sessions. + Click the + button to start a new session.

{:else}
{#each $sortedSessions as session (session.id)} - + deleteSession(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 index 8ec831d..838c6dd 100644 --- a/client/src/routes/session/[id]/+page.svelte +++ b/client/src/routes/session/[id]/+page.svelte @@ -1,13 +1,20 @@