add resizing
This commit is contained in:
@@ -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.
|
||||
@@ -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"}}))
|
||||
```
|
||||
@@ -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 <external-id> \
|
||||
--allowedTools '["Write","Edit"]' \
|
||||
-- <working-dir>
|
||||
```
|
||||
|
||||
**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?`
|
||||
@@ -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)))))
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
```
|
||||
@@ -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})
|
||||
```
|
||||
@@ -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))})
|
||||
Reference in New Issue
Block a user