// Command server is a browser chat playground for claude_pipe. It serves a small // single-page chat UI and, for each message, launches `claude_pipe --stream` as a // subprocess and relays its NDJSON events (text_delta + result) to the browser as // Server-Sent Events. The browser renders the answer token-chunk by token-chunk. // // This proves the whole stack end to end through a real surface: the PTY capture, // the VT render, the TUI parser (with the spinner fix), and the streaming delta — // all driving a live chat in the browser. // // Each message is an independent one-shot claude session: claude_pipe does not keep // conversation context between turns, so the chat has no memory across messages. // // Run: // // cd apps/claude_pipe && CGO_ENABLED=1 go build -tags fts5 -o claude_pipe . // cd playground && go run ./web # then open http://localhost:8099 package main import ( "bufio" _ "embed" "flag" "fmt" "log" "net/http" "os" "os/exec" "path/filepath" ) //go:embed index.html var indexHTML []byte var ( binPath string root string warmup string idle string maxDur string ) func main() { port := flag.String("port", "8099", "port to listen on") bin := flag.String("bin", "/home/enmanuel/fn_registry/apps/claude_pipe/claude_pipe", "path to the claude_pipe binary") rootDir := flag.String("root", "/home/enmanuel/fn_registry", "cwd for claude (a repo whose MCP servers are approved)") wu := flag.String("warmup", "4s", "claude_pipe --warmup") id := flag.String("idle", "4s", "claude_pipe --idle") md := flag.String("max", "120s", "claude_pipe --max") flag.Parse() abs, err := filepath.Abs(*bin) if err != nil { log.Fatalf("resolve --bin: %v", err) } if _, err := os.Stat(abs); err != nil { log.Fatalf("claude_pipe binary not found at %s — build it first:\n (cd .. && CGO_ENABLED=1 go build -tags fts5 -o claude_pipe .)", abs) } binPath, root, warmup, idle, maxDur = abs, *rootDir, *wu, *id, *md http.HandleFunc("/", handleIndex) http.HandleFunc("/chat", handleChat) addr := ":" + *port log.Printf("claude_pipe web chat → http://localhost:%s (bin=%s root=%s)", *port, binPath, root) log.Fatal(http.ListenAndServe(addr, nil)) } 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 runs claude_pipe --stream for the given prompt and relays each NDJSON // line as a Server-Sent Event. The browser opens this via EventSource. 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("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") // The request context is cancelled when the browser closes the EventSource; // CommandContext then kills the claude_pipe subprocess (and its PTY child). cmd := exec.CommandContext(r.Context(), binPath, "--stream", "--cwd", root, "--warmup", warmup, "--idle", idle, "--max", maxDur, prompt, ) stdout, err := cmd.StdoutPipe() if err != nil { sse(w, flusher, "error", fmt.Sprintf(`{"message":%q}`, err.Error())) return } cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { sse(w, flusher, "error", fmt.Sprintf(`{"message":%q}`, err.Error())) return } sc := bufio.NewScanner(stdout) sc.Buffer(make([]byte, 1024*1024), 1024*1024) for sc.Scan() { // Each line is already a JSON object ({"type":"text_delta",...} or result). // Relay it verbatim as the SSE data payload. sse(w, flusher, "", sc.Text()) } _ = cmd.Wait() // Signal completion so the browser can close the stream. sse(w, flusher, "done", "{}") } // sse writes one Server-Sent Event. If event is empty, a default "message" event // is emitted (what EventSource.onmessage receives). 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() }