Sesión claude caliente y persistente. Mantiene contexto entre mensajes (pruébalo: pregunta algo y luego refiérete a ello). Cada respuesta llega del SSE de la red, exacta, en ~2-3s. "Nueva conversación" reinicia la sesión sin memoria.
+
+
+
+
+
Un único daemon claude_session vivo detrás. Mensajes secuenciales. La memoria persiste hasta que reinicies.
+
+
+
+
diff --git a/playground/web/server.go b/playground/web/server.go
new file mode 100644
index 0000000..283e93d
--- /dev/null
+++ b/playground/web/server.go
@@ -0,0 +1,198 @@
+// 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
+}