1a8415950c
Backend Go (web/server.go) que lanza UN claude_session al boot y lo mantiene
vivo: por mensaje escribe {cmd:send} a su stdin y reenvia los eventos NDJSON
(text_delta + result) como SSE. Endpoint /restart -> {cmd:restart}. Frontend
vanilla (web/index.html) con boton Nueva conversacion.
A diferencia de los chats de pipe/wire (proceso nuevo por mensaje), este reusa
la sesion -> memoria entre turnos + ~2.7s/mensaje. Validado end-to-end:
recuerda un dato (42), lo recupera en el turno siguiente, y restart limpia la
memoria. Puerto 8101.
199 lines
5.2 KiB
Go
199 lines
5.2 KiB
Go
// Command server is a browser chat over a single hot claude_session daemon. Unlike
|
|
// the claude_pipe/claude_wire chats (which spawn a fresh one-shot process per
|
|
// message), this server launches ONE claude_session at boot and keeps it alive, so
|
|
// the conversation has memory across turns and each message answers in ~2-3s.
|
|
//
|
|
// Per message it writes {"cmd":"send","prompt":...} to the daemon's stdin and
|
|
// relays the daemon's NDJSON (text_delta + result) to the browser as SSE. A
|
|
// /restart endpoint sends {"cmd":"restart"} to start a fresh conversation.
|
|
//
|
|
// The daemon's stdout is read only while holding a mutex, so requests are
|
|
// serialized (a chat is sequential anyway).
|
|
//
|
|
// Run:
|
|
//
|
|
// cd apps/claude_session && go build -o claude_session .
|
|
// cd playground && go run ./web # http://localhost:8101
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
//go:embed index.html
|
|
var indexHTML []byte
|
|
|
|
type daemonEvent struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
Result string `json:"result"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
stdin io.Writer
|
|
stdout *bufio.Scanner
|
|
session *exec.Cmd
|
|
)
|
|
|
|
func main() {
|
|
port := flag.String("port", "8101", "port to listen on")
|
|
bin := flag.String("session", "/home/enmanuel/fn_registry/apps/claude_session/claude_session", "claude_session binary")
|
|
cwd := flag.String("cwd", "/home/enmanuel/fn_registry", "cwd for the claude session")
|
|
flag.Parse()
|
|
|
|
abs, err := filepath.Abs(*bin)
|
|
if err != nil || !fileExists(abs) {
|
|
log.Fatalf("claude_session binary not found at %s — build it first (cd .. && go build -o claude_session .)", abs)
|
|
}
|
|
|
|
if err := startDaemon(abs, *cwd); err != nil {
|
|
log.Fatalf("start daemon: %v", err)
|
|
}
|
|
|
|
http.HandleFunc("/", handleIndex)
|
|
http.HandleFunc("/chat", handleChat)
|
|
http.HandleFunc("/restart", handleRestart)
|
|
|
|
log.Printf("claude_session web chat → http://localhost:%s (daemon=%s cwd=%s)", *port, abs, *cwd)
|
|
log.Fatal(http.ListenAndServe(":"+*port, nil))
|
|
}
|
|
|
|
// startDaemon launches claude_session and blocks until its initial {"type":"ready"}.
|
|
func startDaemon(bin, cwd string) error {
|
|
cmd := exec.Command(bin, "--cwd", cwd)
|
|
cmd.Stderr = os.Stderr
|
|
in, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
sc := bufio.NewScanner(out)
|
|
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
|
stdin, stdout, session = in, sc, cmd
|
|
|
|
log.Printf("waiting for claude session to warm up...")
|
|
for sc.Scan() {
|
|
var ev daemonEvent
|
|
if json.Unmarshal(sc.Bytes(), &ev) == nil && ev.Type == "ready" {
|
|
log.Printf("claude session ready")
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("daemon exited before ready")
|
|
}
|
|
|
|
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write(indexHTML)
|
|
}
|
|
|
|
// handleChat sends one prompt to the live daemon and relays its events as SSE.
|
|
func handleChat(w http.ResponseWriter, r *http.Request) {
|
|
prompt := r.URL.Query().Get("prompt")
|
|
if prompt == "" {
|
|
http.Error(w, "missing prompt", http.StatusBadRequest)
|
|
return
|
|
}
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
cmd, _ := json.Marshal(map[string]string{"cmd": "send", "prompt": prompt})
|
|
if _, err := fmt.Fprintln(stdin, string(cmd)); err != nil {
|
|
sse(w, flusher, "error", fmt.Sprintf(`{"message":%q}`, err.Error()))
|
|
return
|
|
}
|
|
|
|
// Relay daemon events until result, then consume the trailing ready.
|
|
gotResult := false
|
|
for stdout.Scan() {
|
|
line := stdout.Bytes()
|
|
var ev daemonEvent
|
|
if json.Unmarshal(line, &ev) != nil {
|
|
continue
|
|
}
|
|
switch ev.Type {
|
|
case "text_delta", "result", "error":
|
|
sse(w, flusher, "", string(line))
|
|
if ev.Type == "result" || ev.Type == "error" {
|
|
gotResult = true
|
|
}
|
|
case "ready":
|
|
if gotResult {
|
|
sse(w, flusher, "done", "{}")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
sse(w, flusher, "error", `{"message":"daemon stream ended"}`)
|
|
}
|
|
|
|
// handleRestart tells the daemon to start a fresh conversation.
|
|
func handleRestart(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
if _, err := fmt.Fprintln(stdin, `{"cmd":"restart"}`); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
deadline := time.Now().Add(30 * time.Second)
|
|
for stdout.Scan() {
|
|
var ev daemonEvent
|
|
if json.Unmarshal(stdout.Bytes(), &ev) == nil && ev.Type == "ready" {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
break
|
|
}
|
|
}
|
|
http.Error(w, "restart timeout", http.StatusGatewayTimeout)
|
|
}
|
|
|
|
func sse(w http.ResponseWriter, f http.Flusher, event, data string) {
|
|
if event != "" {
|
|
fmt.Fprintf(w, "event: %s\n", event)
|
|
}
|
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
|
f.Flush()
|
|
}
|
|
|
|
func fileExists(p string) bool {
|
|
_, err := os.Stat(p)
|
|
return err == nil
|
|
}
|