diff --git a/.gitignore b/.gitignore index c284b8c..8a1f444 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ Q-Login *.elc /Assist /anvilsrv +/anvilwebgw diff --git a/cmd/anvilweb/main.go b/cmd/anvilweb/main.go index 5d50734..e9734c4 100644 --- a/cmd/anvilweb/main.go +++ b/cmd/anvilweb/main.go @@ -1,961 +1,52 @@ +// anvilweb - AnviLLM static web frontend server +// +// Serves the AnviLLM web frontend (static files) and proxies /api/* requests +// to anvilwebgw (the HTTP gateway service). Run anvilwebgw separately to +// handle the 9P-connected API endpoints. package main import ( "embed" - "encoding/json" "flag" "fmt" - "io" "io/fs" "log" "net/http" + "net/http/httputil" + "net/url" "os" - "os/exec" - "path/filepath" - "strings" - - "9fans.net/go/plan9" - "9fans.net/go/plan9/client" ) -const terminalCommand = "foot" - //go:embed static/* var static embed.FS var ( - addr = flag.String("addr", ":8080", "HTTP server address") - namespace = flag.String("ns", os.Getenv("NAMESPACE"), "9P namespace") + addr = flag.String("addr", ":8080", "HTTP server address") + gwAddr = flag.String("gw", "http://localhost:8081", "anvilwebgw gateway URL for /api/* requests") ) -type Session struct { - ID string `json:"id"` - Alias string `json:"alias"` - State string `json:"state"` - PID string `json:"pid"` - CWD string `json:"cwd"` - Backend string `json:"backend"` -} - func main() { flag.Parse() - if *namespace == "" { - *namespace = fmt.Sprintf("/tmp/ns.%s.:0", os.Getenv("USER")) + gwURL, err := url.Parse(*gwAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "Invalid gateway URL %q: %v\n", *gwAddr, err) + os.Exit(1) } staticFS, err := fs.Sub(static, "static") if err != nil { log.Fatal(err) } - http.Handle("/", http.FileServer(http.FS(staticFS))) - http.HandleFunc("/api/sessions", handleSessions) - http.HandleFunc("/api/session/", handleSession) - http.HandleFunc("/api/inbox", handleInbox) - http.HandleFunc("/api/inbox/count", handleInboxCount) - http.HandleFunc("/api/inbox/reply", handleInboxReply) - http.HandleFunc("/api/inbox/archive", handleInboxArchive) - http.HandleFunc("/api/archive", handleArchive) - http.HandleFunc("/api/beads", handleBeads) - http.HandleFunc("/api/beads/ready", handleBeadsReady) - http.HandleFunc("/api/beads/ctl", handleBeadsCtl) - http.HandleFunc("/api/beads/", handleBeadDetail) - http.HandleFunc("/api/daemon/status", handleDaemonStatus) - log.Printf("Starting web server on %s", *addr) - log.Printf("Using namespace: %s", *namespace) - log.Fatal(http.ListenAndServe(*addr, nil)) -} - -func connect() (*client.Fsys, error) { - return client.Mount("unix", filepath.Join(*namespace, "agent")) -} - -func handleSessions(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch r.Method { - case "GET": - listSessions(w, r) - case "POST": - createSession(w, r) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -func listSessions(w http.ResponseWriter, r *http.Request) { - fs, err := connect() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer fs.Close() - - fid, err := fs.Open("list", plan9.OREAD) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer fid.Close() - - data, err := io.ReadAll(fid) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - var sessions []Session - for _, line := range strings.Split(string(data), "\n") { - if line == "" { - continue - } - fields := strings.Fields(line) - if len(fields) < 5 { - continue - } - sess := Session{ - ID: fields[0], - Alias: fields[1], - State: fields[2], - PID: fields[3], - CWD: fields[4], - } - - // Read backend for each session - if backendFid, err := fs.Open(filepath.Join(sess.ID, "backend"), plan9.OREAD); err == nil { - if backendData, err := io.ReadAll(backendFid); err == nil { - sess.Backend = strings.TrimSpace(string(backendData)) - } - backendFid.Close() - } - - sessions = append(sessions, sess) - } - - json.NewEncoder(w).Encode(sessions) -} - -func createSession(w http.ResponseWriter, r *http.Request) { - var req struct { - Backend string `json:"backend"` - CWD string `json:"cwd"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - // Validate backend - if req.Backend != "kiro-cli" && req.Backend != "claude" { - http.Error(w, "Invalid backend", http.StatusBadRequest) - return - } - - // Validate path - if !filepath.IsAbs(req.CWD) { - http.Error(w, "Path must be absolute", http.StatusBadRequest) - return - } - if _, err := os.Stat(req.CWD); err != nil { - http.Error(w, "Path does not exist", http.StatusBadRequest) - return - } - - fs, err := connect() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer fs.Close() - - fid, err := fs.Open("ctl", plan9.OWRITE) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer fid.Close() - - cmd := fmt.Sprintf("new %s %s", req.Backend, req.CWD) - if _, err := fid.Write([]byte(cmd)); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusCreated) -} - -func handleSession(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/session/"), "/") - if len(parts) < 1 { - http.Error(w, "Invalid path", http.StatusBadRequest) - return - } - - id := parts[0] - action := "" - if len(parts) > 1 { - action = parts[1] - } - - switch r.Method { - case "GET": - if action == "" { - getSession(w, r, id) - } else { - getSessionFile(w, r, id, action) - } - case "POST": - if action == "prompt" { - sendPrompt(w, r, id) - } else if action == "prompt-direct" { - sendPromptDirect(w, r, id) - } else if action == "ctl" { - controlSession(w, r, id) - } else { - http.Error(w, "Invalid action", http.StatusBadRequest) - } - case "PUT": - if action == "alias" || action == "context" { - updateSessionFile(w, r, id, action) - } else { - http.Error(w, "Invalid action", http.StatusBadRequest) - } - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -func getSession(w http.ResponseWriter, r *http.Request, id string) { - fs, err := connect() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer fs.Close() - - sess := Session{ID: id} - - // Read state - if fid, err := fs.Open(filepath.Join(id, "state"), plan9.OREAD); err == nil { - if data, err := io.ReadAll(fid); err == nil { - sess.State = strings.TrimSpace(string(data)) - } - fid.Close() - } - - // Read alias - if fid, err := fs.Open(filepath.Join(id, "alias"), plan9.OREAD); err == nil { - if data, err := io.ReadAll(fid); err == nil { - sess.Alias = strings.TrimSpace(string(data)) - } - fid.Close() - } - - // Read backend - if fid, err := fs.Open(filepath.Join(id, "backend"), plan9.OREAD); err == nil { - if data, err := io.ReadAll(fid); err == nil { - sess.Backend = strings.TrimSpace(string(data)) - } - fid.Close() - } - - // Read pid - if fid, err := fs.Open(filepath.Join(id, "pid"), plan9.OREAD); err == nil { - if data, err := io.ReadAll(fid); err == nil { - sess.PID = strings.TrimSpace(string(data)) - } - fid.Close() - } - - // Read cwd - if fid, err := fs.Open(filepath.Join(id, "cwd"), plan9.OREAD); err == nil { - if data, err := io.ReadAll(fid); err == nil { - sess.CWD = strings.TrimSpace(string(data)) - } - fid.Close() - } - - json.NewEncoder(w).Encode(sess) -} - -func getSessionTmux(id string) string { - fs, err := connect() - if err != nil { - return "" - } - defer fs.Close() - - fid, err := fs.Open(filepath.Join(id, "tmux"), plan9.OREAD) - if err != nil { - return "" - } - defer fid.Close() - - data, err := io.ReadAll(fid) - if err != nil { - return "" - } - return strings.TrimSpace(string(data)) -} - -func getSessionFile(w http.ResponseWriter, r *http.Request, id, file string) { - fs, err := connect() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer fs.Close() - - fid, err := fs.Open(filepath.Join(id, file), plan9.OREAD) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer fid.Close() - - data, err := io.ReadAll(fid) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - json.NewEncoder(w).Encode(map[string]string{ - "content": string(data), - }) -} - -func sendPrompt(w http.ResponseWriter, r *http.Request, id string) { - var req struct { - Prompt string `json:"prompt"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - if strings.TrimSpace(req.Prompt) == "" { - http.Error(w, "Prompt cannot be empty", http.StatusBadRequest) - return - } - - fs, err := connect() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer fs.Close() - - // Create message JSON - msg := map[string]interface{}{ - "to": id, - "type": "PROMPT_REQUEST", - "subject": "User prompt", - "body": req.Prompt, - } - msgJSON, err := json.Marshal(msg) - if err != nil { - http.Error(w, fmt.Sprintf("failed to marshal message: %v", err), http.StatusInternalServerError) - return - } - - // Write to user mail - path := "user/mail" - - fid, err := fs.Open(path, plan9.OWRITE) - if err != nil { - http.Error(w, fmt.Sprintf("failed to open mail file: %v", err), http.StatusInternalServerError) - return - } - defer fid.Close() - - if _, err := fid.Write(msgJSON); err != nil { - http.Error(w, fmt.Sprintf("failed to write message: %v", err), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -func sendPromptDirect(w http.ResponseWriter, r *http.Request, id string) { - var req struct { - Prompt string `json:"prompt"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - if strings.TrimSpace(req.Prompt) == "" { - http.Error(w, "Prompt cannot be empty", http.StatusBadRequest) - return - } - - fs, err := connect() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer fs.Close() - - path := filepath.Join(id, "in") - fid, err := fs.Open(path, plan9.OWRITE) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer fid.Close() - - if _, err := fid.Write([]byte(req.Prompt)); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -func controlSession(w http.ResponseWriter, r *http.Request, id string) { - var req struct { - Command string `json:"command"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - // Validate command - validCmds := map[string]bool{"stop": true, "restart": true, "kill": true, "refresh": true, "attach": true} - if !validCmds[req.Command] { - http.Error(w, "Invalid command", http.StatusBadRequest) - return - } - - // Handle attach specially - launch terminal with tmux attach - if req.Command == "attach" { - tmuxTarget := getSessionTmux(id) - if tmuxTarget == "" { - http.Error(w, "Session does not support attach", http.StatusBadRequest) - return - } - cmd := exec.Command(terminalCommand, "-e", "tmux", "attach", "-t", tmuxTarget) - if err := cmd.Start(); err != nil { - http.Error(w, "Failed to launch terminal: "+err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) - return - } - - fs, err := connect() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer fs.Close() - - fid, err := fs.Open(filepath.Join(id, "ctl"), plan9.OWRITE) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer fid.Close() - - if _, err := fid.Write([]byte(req.Command)); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -func updateSessionFile(w http.ResponseWriter, r *http.Request, id, file string) { - var req struct { - Content string `json:"content"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - fs, err := connect() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer fs.Close() - - fid, err := fs.Open(filepath.Join(id, file), plan9.OWRITE) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer fid.Close() - - if _, err := fid.Write([]byte(req.Content)); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -type Message struct { - ID string `json:"id"` - From string `json:"from"` - To string `json:"to"` - Type string `json:"type"` - Subject string `json:"subject"` - Body string `json:"body"` -} - -type InboxMessage struct { - Message - Filename string `json:"filename"` - Timestamp int64 `json:"timestamp"` -} - -func handleInbox(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - fs, err := connect() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer fs.Close() - - fid, err := fs.Open("user/inbox", plan9.OREAD) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer fid.Close() - - messages := []InboxMessage{} - for { - dirs, err := fid.Dirread() - if err != nil || len(dirs) == 0 { - break - } - for _, d := range dirs { - if !strings.HasSuffix(d.Name, ".json") { - continue - } - - msgFid, err := fs.Open(filepath.Join("user/inbox", d.Name), plan9.OREAD) - if err != nil { - continue - } - data, err := io.ReadAll(msgFid) - msgFid.Close() - if err != nil { - continue - } - - var msg Message - if err := json.Unmarshal(data, &msg); err != nil { - continue - } - - var ts int64 - parts := strings.Split(strings.TrimSuffix(d.Name, ".json"), "-") - if len(parts) == 2 { - fmt.Sscanf(parts[1], "%d", &ts) - } - - messages = append(messages, InboxMessage{ - Message: msg, - Filename: d.Name, - Timestamp: ts, - }) - } - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(messages) -} - -func handleInboxCount(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - fs, err := connect() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer fs.Close() - - fid, err := fs.Open("user/inbox", plan9.OREAD) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer fid.Close() - - count := 0 - for { - dirs, err := fid.Dirread() - if err != nil || len(dirs) == 0 { - break - } - for _, d := range dirs { - if strings.HasSuffix(d.Name, ".json") { - count++ - } - } - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]int{"count": count}) -} - -func handleInboxReply(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req struct { - To string `json:"to"` - Type string `json:"type"` - Subject string `json:"subject"` - Body string `json:"body"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - fs, err := connect() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer fs.Close() - - msg := map[string]interface{}{ - "to": req.To, - "type": req.Type, - "subject": req.Subject, - "body": req.Body, - } - msgJSON, err := json.Marshal(msg) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - path := "user/mail" - - fid, err := fs.Open(path, plan9.OWRITE) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer fid.Close() - - if _, err := fid.Write(msgJSON); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"status": "sent"}) -} - -func handleInboxArchive(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req struct { - Filename string `json:"filename"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - fs, err := connect() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer fs.Close() - - ctlMsg := fmt.Sprintf("complete %s", req.Filename) - fid, err := fs.Open("user/ctl", plan9.OWRITE) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer fid.Close() - - if _, err := fid.Write([]byte(ctlMsg)); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"status": "archived"}) -} - -func handleArchive(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - fs, err := connect() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer fs.Close() - - fid, err := fs.Open("user/completed", plan9.OREAD) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer fid.Close() - - messages := []InboxMessage{} - for { - dirs, err := fid.Dirread() - if err != nil || len(dirs) == 0 { - break - } - for _, d := range dirs { - if !strings.HasSuffix(d.Name, ".json") { - continue - } - - msgFid, err := fs.Open(filepath.Join("user/completed", d.Name), plan9.OREAD) - if err != nil { - continue - } - data, err := io.ReadAll(msgFid) - msgFid.Close() - if err != nil { - continue - } - - var msg Message - if err := json.Unmarshal(data, &msg); err != nil { - continue - } - - var ts int64 - parts := strings.Split(strings.TrimSuffix(d.Name, ".json"), "-") - if len(parts) == 2 { - fmt.Sscanf(parts[1], "%d", &ts) - } - - messages = append(messages, InboxMessage{ - Message: msg, - Filename: d.Name, - Timestamp: ts, - }) - } - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(messages) -} - -func handleBeads(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - fs, err := connect() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer fs.Close() - - fid, err := fs.Open("beads/list", plan9.OREAD) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer fid.Close() - - data, err := io.ReadAll(fid) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.Write(data) -} - -func handleBeadsReady(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - fs, err := connect() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer fs.Close() - - fid, err := fs.Open("beads/ready", plan9.OREAD) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer fid.Close() - - data, err := io.ReadAll(fid) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.Write(data) -} - -func handleBeadsCtl(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req struct { - Command string `json:"command"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - fs, err := connect() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer fs.Close() - - fid, err := fs.Open("beads/ctl", plan9.OWRITE) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer fid.Close() - - if _, err := fid.Write([]byte(req.Command)); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) -} - -func handleBeadDetail(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - id := strings.TrimPrefix(r.URL.Path, "/api/beads/") - if id == "" { - http.Error(w, "Invalid bead ID", http.StatusBadRequest) - return - } - - fs, err := connect() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer fs.Close() - - fid, err := fs.Open(filepath.Join("beads", id, "json"), plan9.OREAD) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer fid.Close() - - data, err := io.ReadAll(fid) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.Write(data) -} - -func handleDaemonStatus(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - fs, err := connect() - if err != nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "running": false, - "error": err.Error(), - }) - return - } - defer fs.Close() - - // If we can connect, daemon is running - // Try to get session count - fid, err := fs.Open("list", plan9.OREAD) - if err != nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "running": true, - "session_count": 0, - }) - return - } - defer fid.Close() - - data, _ := io.ReadAll(fid) - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - count := 0 - for _, line := range lines { - if line != "" { - count++ - } - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "running": true, - "session_count": count, - }) + // Reverse proxy for /api/* to anvilwebgw + proxy := httputil.NewSingleHostReverseProxy(gwURL) + + mux := http.NewServeMux() + mux.Handle("/api/", proxy) + mux.Handle("/", http.FileServer(http.FS(staticFS))) + + log.Printf("Starting anvilweb on %s", *addr) + log.Printf("Proxying /api/* to %s", *gwAddr) + log.Fatal(http.ListenAndServe(*addr, mux)) } diff --git a/cmd/anvilweb/static/index.html b/cmd/anvilweb/static/index.html index b176535..7d98016 100644 --- a/cmd/anvilweb/static/index.html +++ b/cmd/anvilweb/static/index.html @@ -7,22 +7,22 @@ @@ -173,6 +227,16 @@
+ + +

Sessions

@@ -411,7 +475,42 @@ let sortAsc = true; let previousStates = {}; - // Request notification permission on load + // ── Error handling ───────────────────────────────────────────── + + // Extract a meaningful error message from a non-OK Response. + async function apiError(res) { + let body = ''; + try { body = (await res.text()).trim(); } catch (_) {} + const status = `${res.status} ${res.statusText}`.trim(); + return body ? `${status}: ${body}` : status; + } + + // Show the global dismissible error banner. + let errorDismissTimer = null; + function showError(detail, title = 'Error') { + clearTimeout(errorDismissTimer); + document.getElementById('error-title').textContent = title; + document.getElementById('error-detail').textContent = detail; + document.getElementById('error-banner').classList.remove('hidden'); + // Auto-dismiss after 10 s + errorDismissTimer = setTimeout(dismissError, 10000); + } + + function dismissError() { + clearTimeout(errorDismissTimer); + document.getElementById('error-banner').classList.add('hidden'); + } + + // Returns HTML for an inline error inside a container. + function errorHTML(detail) { + return ``; + } + + // ── Notifications ────────────────────────────────────────────── + if ('Notification' in window && Notification.permission === 'default') { Notification.requestPermission(); } @@ -432,6 +531,8 @@ return div.innerHTML; } + // ── Action menu ──────────────────────────────────────────────── + let openMenuSessionId = null; function toggleMenu(btn) { @@ -451,6 +552,8 @@ } }); + // ── Sessions ─────────────────────────────────────────────────── + function filterSessions() { const query = document.getElementById('search-box').value.toLowerCase(); const filtered = allSessions.filter(s => { @@ -464,7 +567,6 @@ return searchText.includes(query); }); renderSessions(filtered); - // Restore open menu after render if (openMenuSessionId) { const btn = document.querySelector(`[data-session-id="${openMenuSessionId}"]`)?.closest('tr')?.querySelector('.action-menu-btn'); if (btn) btn.closest('.action-menu').classList.add('open'); @@ -541,16 +643,20 @@ `; - + container.innerHTML = table; } async function loadSessions() { try { const res = await fetch('/api/sessions'); + if (!res.ok) { + const msg = await apiError(res); + document.getElementById('sessions-container').innerHTML = errorHTML('Failed to load sessions: ' + msg); + return; + } const sessions = await res.json(); - - // Check for state transitions and notify + for (const s of sessions) { const oldState = previousStates[s.id]; if (oldState && oldState !== s.state) { @@ -558,12 +664,11 @@ } previousStates[s.id] = s.state; } - + allSessions = sessions; filterSessions(); } catch (err) { - console.error('Failed to load sessions:', err); - document.getElementById('sessions-container').innerHTML = '

Failed to connect to server

'; + document.getElementById('sessions-container').innerHTML = errorHTML('Failed to connect to server: ' + err.message); } } @@ -580,7 +685,7 @@ e.preventDefault(); const form = e.target; const data = new FormData(form); - + try { const res = await fetch('/api/sessions', { method: 'POST', @@ -590,16 +695,16 @@ cwd: data.get('cwd') }) }); - + if (res.ok) { form.reset(); closeNewSessionDialog(); setTimeout(loadSessions, 500); } else { - alert('Failed to create session'); + showError(await apiError(res), 'Failed to create session'); } } catch (err) { - alert('Error: ' + err.message); + showError(err.message, 'Failed to create session'); } }); @@ -617,34 +722,31 @@ document.getElementById('prompt-form').addEventListener('submit', async (e) => { e.preventDefault(); let prompt = document.getElementById('prompt-area').value; - - // Check if prompt is a bead ID and wrap it + const trimmedPrompt = prompt.trim(); if (isBeadID(trimmedPrompt)) { prompt = `Load the beads skill, and work on bead ${trimmedPrompt}.`; } - + try { const res = await fetch(`/api/session/${currentSessionId}/prompt`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt }) }); - + if (res.ok) { closePromptDialog(); loadSessions(); } else { - alert('Failed to send prompt'); + showError(await apiError(res), 'Failed to send prompt'); } } catch (err) { - alert('Error: ' + err.message); + showError(err.message, 'Failed to send prompt'); } }); function isBeadID(text) { - // Match bead ID pattern: prefix-xxx or prefix-xxx.yyy (hierarchical) - // Common prefixes: bd, task, bug, etc. return /^[a-zA-Z]+-[a-z0-9]+(\.[0-9]+)*$/.test(text); } @@ -652,11 +754,15 @@ currentSessionId = id; try { const res = await fetch(`/api/session/${id}/context`); + if (!res.ok) { + showError(await apiError(res), 'Failed to load context'); + return; + } const data = await res.json(); document.getElementById('context-area').value = data.content || ''; document.getElementById('context-dialog').showModal(); } catch (err) { - alert('Error loading context: ' + err.message); + showError(err.message, 'Failed to load context'); } } @@ -667,21 +773,21 @@ document.getElementById('context-form').addEventListener('submit', async (e) => { e.preventDefault(); const content = document.getElementById('context-area').value; - + try { const res = await fetch(`/api/session/${currentSessionId}/context`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }); - + if (res.ok) { closeContextDialog(); } else { - alert('Failed to save context'); + showError(await apiError(res), 'Failed to save context'); } } catch (err) { - alert('Error: ' + err.message); + showError(err.message, 'Failed to save context'); } }); @@ -689,11 +795,15 @@ currentSessionId = id; try { const res = await fetch(`/api/session/${id}/alias`); + if (!res.ok) { + showError(await apiError(res), 'Failed to load alias'); + return; + } const data = await res.json(); document.getElementById('alias-input').value = data.content || ''; document.getElementById('alias-dialog').showModal(); } catch (err) { - alert('Error loading alias: ' + err.message); + showError(err.message, 'Failed to load alias'); } } @@ -704,22 +814,22 @@ document.getElementById('alias-form').addEventListener('submit', async (e) => { e.preventDefault(); const content = document.getElementById('alias-input').value; - + try { const res = await fetch(`/api/session/${currentSessionId}/alias`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }); - + if (res.ok) { closeAliasDialog(); loadSessions(); } else { - alert('Failed to save alias'); + showError(await apiError(res), 'Failed to save alias'); } } catch (err) { - alert('Error: ' + err.message); + showError(err.message, 'Failed to save alias'); } }); @@ -730,14 +840,14 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: cmd }) }); - + if (res.ok) { setTimeout(loadSessions, 500); } else { - alert(`Failed to ${cmd} session`); + showError(await apiError(res), `Failed to ${cmd} session`); } } catch (err) { - alert('Error: ' + err.message); + showError(err.message, `Failed to ${cmd} session`); } } @@ -752,14 +862,13 @@ body: JSON.stringify({ command: 'attach' }) }); if (!res.ok) { - const text = await res.text(); - alert(text || 'Session does not support attach (not tmux-based)'); + showError(await apiError(res), 'Failed to attach session'); } } catch (err) { - alert('Error: ' + err.message); + showError(err.message, 'Failed to attach session'); } } - + // Auto-refresh every 5 seconds setInterval(() => { loadSessions(); @@ -790,10 +899,10 @@ document.addEventListener('click', (e) => { const btn = e.target.closest('button[data-session-id]'); if (!btn || btn.disabled) return; - + const id = btn.dataset.sessionId; const action = btn.dataset.action; - + switch (action) { case 'attach': attachSession(id); break; case 'prompt': openPromptDialog(id); break; @@ -806,7 +915,8 @@ } }); - // Work On dialog functionality + // ── Work On dialog ───────────────────────────────────────────── + let selectedBeadId = null; async function openWorkOnDialog(sessionId) { @@ -815,14 +925,16 @@ document.getElementById('work-on-start-btn').disabled = true; document.getElementById('work-on-dialog').showModal(); - // Load ready beads try { - const response = await fetch('/api/beads/ready'); - const readyBeads = await response.json(); + const res = await fetch('/api/beads/ready'); + if (!res.ok) { + document.getElementById('work-on-content').innerHTML = errorHTML('Failed to load ready tasks: ' + await apiError(res)); + return; + } + const readyBeads = await res.json(); renderWorkOnBeads(readyBeads); } catch (error) { - console.error('Failed to load ready beads:', error); - document.getElementById('work-on-content').innerHTML = '

Failed to load ready tasks

'; + document.getElementById('work-on-content').innerHTML = errorHTML('Failed to load ready tasks: ' + error.message); } } @@ -852,7 +964,6 @@ function selectBeadForWork(beadId) { selectedBeadId = beadId; - // Highlight selected row document.querySelectorAll('#work-on-content tr[data-bead-id]').forEach(row => { if (row.dataset.beadId === beadId) { row.classList.add('selected'); @@ -880,10 +991,10 @@ closeWorkOnDialog(); loadSessions(); } else { - alert('Failed to send prompt'); + showError(await apiError(res), 'Failed to send prompt'); } } catch (err) { - alert('Error: ' + err.message); + showError(err.message, 'Failed to send prompt'); } } @@ -893,7 +1004,8 @@ document.getElementById('work-on-start-btn').disabled = true; } - // Tab switching + // ── Tab switching ────────────────────────────────────────────── + function showTab(tab) { document.getElementById('sessions-tab').classList.toggle('hidden', tab !== 'sessions'); document.getElementById('inbox-tab').classList.toggle('hidden', tab !== 'inbox'); @@ -905,7 +1017,6 @@ } else if (tab === 'archive') { loadArchive(); } else if (tab === 'tasks') { - // Update filter button styles to match current filter document.querySelectorAll('[id^="filter-"]').forEach(btn => { btn.className = 'secondary'; }); @@ -914,7 +1025,8 @@ } } - // Inbox functionality + // ── Inbox ────────────────────────────────────────────────────── + let currentMessage = null; let inboxMessages = []; let archiveMessages = []; @@ -922,6 +1034,7 @@ async function updateInboxBadge() { try { const response = await fetch('/api/inbox/count'); + if (!response.ok) return; // silent — badge is non-critical const data = await response.json(); const badge = document.getElementById('inbox-badge'); if (data.count > 0) { @@ -930,23 +1043,28 @@ } else { badge.classList.add('hidden'); } - } catch (error) { - console.error('Failed to update inbox badge:', error); + } catch (_) { + // Non-critical; swallow silently } } async function loadInbox() { try { const response = await fetch('/api/inbox'); + if (!response.ok) { + document.getElementById('inbox-container').innerHTML = errorHTML('Failed to load inbox: ' + await apiError(response)); + return; + } const messages = await response.json(); renderInbox(messages); updateInboxBadge(); } catch (error) { - console.error('Failed to load inbox:', error); + document.getElementById('inbox-container').innerHTML = errorHTML('Failed to load inbox: ' + error.message); } } function renderInbox(messages) { + messages = messages || []; messages.sort((a, b) => b.timestamp - a.timestamp); inboxMessages = messages; @@ -980,14 +1098,19 @@ async function loadArchive() { try { const response = await fetch('/api/archive'); + if (!response.ok) { + document.getElementById('archive-container').innerHTML = errorHTML('Failed to load archive: ' + await apiError(response)); + return; + } const messages = await response.json(); renderArchive(messages); } catch (error) { - console.error('Failed to load archive:', error); + document.getElementById('archive-container').innerHTML = errorHTML('Failed to load archive: ' + error.message); } } function renderArchive(messages) { + messages = messages || []; messages.sort((a, b) => b.timestamp - a.timestamp); archiveMessages = messages; @@ -1060,15 +1183,15 @@ ${msg.body}`; function replyToMessage() { if (!currentMessage) return; - + const replyType = getReplyType(currentMessage.type); const replySubject = `Re: ${currentMessage.subject}`; - + document.getElementById('reply-to').value = currentMessage.from; document.getElementById('reply-type').value = replyType; document.getElementById('reply-subject').value = replySubject; document.getElementById('reply-body').value = ''; - + closeMessageDialog(); document.getElementById('reply-dialog').showModal(); } @@ -1088,11 +1211,10 @@ ${msg.body}`; if (response.ok) { await loadInbox(); } else { - alert('Failed to archive message'); + showError(await apiError(response), 'Failed to archive message'); } } catch (error) { - console.error('Failed to archive message:', error); - alert('Failed to archive message'); + showError(error.message, 'Failed to archive message'); } } @@ -1127,15 +1249,15 @@ ${msg.body}`; closeReplyDialog(); loadInbox(); } else { - alert('Failed to send reply'); + showError(await apiError(response), 'Failed to send reply'); } } catch (error) { - console.error('Failed to send reply:', error); - alert('Failed to send reply'); + showError(error.message, 'Failed to send reply'); } }); - // Tasks/Beads functionality + // ── Tasks / Beads ────────────────────────────────────────────── + let allBeads = []; let currentBead = null; let beadStatusFilter = 'open'; @@ -1143,19 +1265,21 @@ ${msg.body}`; async function loadBeads() { try { const response = await fetch('/api/beads'); + if (!response.ok) { + document.getElementById('tasks-container').innerHTML = errorHTML('Failed to load tasks: ' + await apiError(response)); + return; + } const beads = await response.json(); allBeads = beads; filterBeads(); } catch (error) { - console.error('Failed to load beads:', error); - document.getElementById('tasks-container').innerHTML = '

Failed to load tasks

'; + document.getElementById('tasks-container').innerHTML = errorHTML('Failed to load tasks: ' + error.message); } } function setBeadFilter(status) { beadStatusFilter = status; - // Update button styles document.querySelectorAll('[id^="filter-"]').forEach(btn => { btn.className = 'secondary'; }); @@ -1167,12 +1291,10 @@ ${msg.body}`; function filterBeads() { const query = document.getElementById('tasks-search-box').value.toLowerCase(); const filtered = allBeads.filter(b => { - // Filter by status if (beadStatusFilter !== 'all' && b.status !== beadStatusFilter) { return false; } - // Filter by search query const searchText = [ b.id || '', b.title || '', @@ -1193,7 +1315,7 @@ ${msg.body}`; } let html = ''; - + for (const bead of beads) { const statusClass = `bead-status-${bead.status}`; html += ` @@ -1209,7 +1331,7 @@ ${msg.body}`; `; } - + html += '
IDTitleStatusPriorityAssigneeActions
'; container.innerHTML = html; } @@ -1228,9 +1350,9 @@ ${msg.body}`; const formData = new FormData(e.target); const title = formData.get('title'); const description = formData.get('description'); - + const command = `new '${title.replace(/'/g, "\\'")}' '${description.replace(/'/g, "\\'")}'`; - + try { const response = await fetch('/api/beads/ctl', { method: 'POST', @@ -1242,20 +1364,23 @@ ${msg.body}`; closeNewBeadDialog(); setTimeout(loadBeads, 500); } else { - alert('Failed to create task'); + showError(await apiError(response), 'Failed to create task'); } } catch (error) { - console.error('Failed to create task:', error); - alert('Failed to create task'); + showError(error.message, 'Failed to create task'); } }); async function viewBead(id) { try { const response = await fetch(`/api/beads/${id}`); + if (!response.ok) { + showError(await apiError(response), 'Failed to load task details'); + return; + } const bead = await response.json(); currentBead = bead; - + const statusClass = `bead-status-${bead.status}`; let content = `

ID: ${escapeHtml(bead.id)}

`; content += `

Status: ${escapeHtml(bead.status)}

`; @@ -1265,17 +1390,16 @@ ${msg.body}`; content += `

Created: ${new Date(bead.created_at).toLocaleString()}

`; content += `

Updated: ${new Date(bead.updated_at).toLocaleString()}

`; content += `

Description:

${escapeHtml(bead.description || '')}

`; - + if (bead.blockers && bead.blockers.length > 0) { content += `

Blockers: ${bead.blockers.map(b => escapeHtml(b)).join(', ')}

`; } - + document.getElementById('bead-detail-title').textContent = bead.title; document.getElementById('bead-detail-content').innerHTML = content; document.getElementById('bead-detail-dialog').showModal(); } catch (error) { - console.error('Failed to load bead details:', error); - alert('Failed to load task details'); + showError(error.message, 'Failed to load task details'); } } @@ -1295,11 +1419,10 @@ ${msg.body}`; if (response.ok) { setTimeout(loadBeads, 500); } else { - alert('Failed to claim task'); + showError(await apiError(response), 'Failed to claim task'); } } catch (error) { - console.error('Failed to claim task:', error); - alert('Failed to claim task'); + showError(error.message, 'Failed to claim task'); } } @@ -1314,25 +1437,30 @@ ${msg.body}`; if (response.ok) { setTimeout(loadBeads, 500); } else { - alert('Failed to complete task'); + showError(await apiError(response), 'Failed to complete task'); } } catch (error) { - console.error('Failed to complete task:', error); - alert('Failed to complete task'); + showError(error.message, 'Failed to complete task'); } } // Tasks search box filtering document.getElementById('tasks-search-box').addEventListener('input', filterBeads); - // Daemon status + // ── Daemon status ────────────────────────────────────────────── + async function updateDaemonStatus() { try { const response = await fetch('/api/daemon/status'); + if (!response.ok) { + document.getElementById('daemon-indicator').className = 'daemon-status-indicator daemon-stopped'; + document.getElementById('daemon-text').textContent = 'Daemon: Error'; + return; + } const data = await response.json(); const indicator = document.getElementById('daemon-indicator'); const text = document.getElementById('daemon-text'); - + if (data.running) { indicator.className = 'daemon-status-indicator daemon-running'; text.textContent = `Daemon: ${data.session_count} sessions`; @@ -1340,15 +1468,12 @@ ${msg.body}`; indicator.className = 'daemon-status-indicator daemon-stopped'; text.textContent = 'Daemon: Stopped'; } - } catch (error) { - const indicator = document.getElementById('daemon-indicator'); - const text = document.getElementById('daemon-text'); - indicator.className = 'daemon-status-indicator daemon-stopped'; - text.textContent = 'Daemon: Error'; + } catch (_) { + document.getElementById('daemon-indicator').className = 'daemon-status-indicator daemon-stopped'; + document.getElementById('daemon-text').textContent = 'Daemon: Unreachable'; } } - // Update daemon status on load and periodically updateDaemonStatus(); setInterval(updateDaemonStatus, 5000); diff --git a/cmd/anvilwebgw/main.go b/cmd/anvilwebgw/main.go new file mode 100644 index 0000000..3429e3b --- /dev/null +++ b/cmd/anvilwebgw/main.go @@ -0,0 +1,1090 @@ +// anvilwebgw - AnviLLM HTTP Gateway Service +// +// Standalone HTTP gateway that exposes the 9P filesystem served by anvilsrv +// as an HTTP API. Supports start, stop, fgstart, and status subcommands. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "9fans.net/go/plan9" + "9fans.net/go/plan9/client" +) + +const terminalCommand = "foot" + +var ( + addr = flag.String("addr", ":8081", "HTTP gateway server address") + namespace = flag.String("ns", os.Getenv("NAMESPACE"), "9P namespace path") +) + +func getPidFilePath() string { + ns := client.Namespace() + if ns == "" { + return filepath.Join("/tmp", "anvilwebgw.pid") + } + return filepath.Join(ns, "anvilwebgw.pid") +} + +func main() { + flag.Parse() + + args := flag.Args() + if len(args) < 1 { + usage() + os.Exit(1) + } + + switch args[0] { + case "start": + startCmd(true) + case "fgstart": + startCmd(false) + case "stop": + stopCmd() + case "status": + statusCmd() + default: + usage() + os.Exit(1) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, "Usage: %s [flags] {start|fgstart|stop|status}\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, "Commands:\n") + fmt.Fprintf(os.Stderr, " start - Start the gateway daemon (daemonized)\n") + fmt.Fprintf(os.Stderr, " fgstart - Start in foreground (for debugging)\n") + fmt.Fprintf(os.Stderr, " stop - Stop the running daemon\n") + fmt.Fprintf(os.Stderr, " status - Check daemon status\n") + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, "Flags:\n") + flag.PrintDefaults() +} + +func startCmd(daemonize bool) { + pidFile := getPidFilePath() + + // Check if already running before daemonizing + if existingPid := readPidFile(); existingPid != 0 { + if isProcessRunning(existingPid) { + log.Fatalf("anvilwebgw already running (PID %d)", existingPid) + } + // Stale PID file + os.Remove(pidFile) + } + + // Daemonize if requested + if daemonize { + if os.Getenv("ANVILWEBGW_DAEMON") != "1" { + cmd, err := os.Executable() + if err != nil { + log.Fatalf("Failed to get executable path: %v", err) + } + // Reconstruct args: flags + fgstart + fwdArgs := []string{"fgstart"} + flag.Visit(func(f *flag.Flag) { + fwdArgs = append([]string{"-" + f.Name + "=" + f.Value.String()}, fwdArgs...) + }) + + attr := &os.ProcAttr{ + Dir: "/", + Env: append(os.Environ(), "ANVILWEBGW_DAEMON=1"), + Files: []*os.File{nil, nil, nil}, + } + + proc, err := os.StartProcess(cmd, append([]string{cmd}, fwdArgs...), attr) + if err != nil { + log.Fatalf("Failed to daemonize: %v", err) + } + proc.Release() + + for i := 0; i < 10; i++ { + if readPidFile() != 0 { + fmt.Fprintf(os.Stderr, "anvilwebgw started successfully\n") + return + } + time.Sleep(100 * time.Millisecond) + } + fmt.Fprintf(os.Stderr, "anvilwebgw started (daemon mode)\n") + return + } + } + + // We're the daemon (or running in foreground) + pid := os.Getpid() + f, err := os.OpenFile(pidFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + if err != nil { + if existingPid := readPidFile(); existingPid != 0 { + if isProcessRunning(existingPid) { + log.Fatalf("anvilwebgw already running (PID %d)", existingPid) + } + os.Remove(pidFile) + f, err = os.OpenFile(pidFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + if err != nil { + log.Fatalf("Failed to create PID file: %v", err) + } + } else { + log.Fatalf("Failed to create PID file: %v", err) + } + } + if _, err := fmt.Fprintf(f, "%d\n", pid); err != nil { + f.Close() + os.Remove(pidFile) + log.Fatalf("Failed to write PID file: %v", err) + } + f.Close() + defer os.Remove(pidFile) + + if *namespace == "" { + *namespace = fmt.Sprintf("/tmp/ns.%s.:0", os.Getenv("USER")) + } + + // Register HTTP handlers + mux := http.NewServeMux() + mux.HandleFunc("/api/sessions", handleSessions) + mux.HandleFunc("/api/session/", handleSession) + mux.HandleFunc("/api/inbox", handleInbox) + mux.HandleFunc("/api/inbox/count", handleInboxCount) + mux.HandleFunc("/api/inbox/reply", handleInboxReply) + mux.HandleFunc("/api/inbox/archive", handleInboxArchive) + mux.HandleFunc("/api/archive", handleArchive) + mux.HandleFunc("/api/beads", handleBeads) + mux.HandleFunc("/api/beads/ready", handleBeadsReady) + mux.HandleFunc("/api/beads/ctl", handleBeadsCtl) + mux.HandleFunc("/api/beads/", handleBeadDetail) + mux.HandleFunc("/api/daemon/status", handleDaemonStatus) + + server := &http.Server{ + Addr: *addr, + Handler: mux, + } + + // Setup signal handling + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigChan + log.Printf("Received signal %v, shutting down", sig) + server.Close() + }() + + log.Printf("anvilwebgw started successfully") + log.Printf("HTTP gateway listening on %s", *addr) + log.Printf("Using 9P namespace: %s", *namespace) + + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } +} + +func stopCmd() { + pidFile := getPidFilePath() + pid := readPidFile() + if pid == 0 { + fmt.Fprintf(os.Stderr, "anvilwebgw is not running\n") + os.Exit(1) + } + + if !isProcessRunning(pid) { + fmt.Fprintf(os.Stderr, "anvilwebgw is not running (stale PID file)\n") + os.Remove(pidFile) + os.Exit(1) + } + + process, err := os.FindProcess(pid) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to find process: %v\n", err) + os.Exit(1) + } + + if err := process.Signal(syscall.SIGTERM); err != nil { + fmt.Fprintf(os.Stderr, "Failed to send SIGTERM: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Sent SIGTERM to anvilwebgw (PID %d)\n", pid) +} + +func statusCmd() { + pid := readPidFile() + if pid == 0 { + fmt.Println("anvilwebgw is not running") + os.Exit(1) + } + + if !isProcessRunning(pid) { + fmt.Println("anvilwebgw is not running (stale PID file)") + os.Exit(1) + } + + fmt.Printf("anvilwebgw is running (PID %d)\n", pid) +} + +func readPidFile() int { + data, err := os.ReadFile(getPidFilePath()) + if err != nil { + return 0 + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + return 0 + } + return pid +} + +func isProcessRunning(pid int) bool { + process, err := os.FindProcess(pid) + if err != nil { + return false + } + return process.Signal(syscall.Signal(0)) == nil +} + +// connect opens a 9P connection to anvilsrv +func connect() (*client.Fsys, error) { + ns := *namespace + if ns == "" { + ns = fmt.Sprintf("/tmp/ns.%s.:0", os.Getenv("USER")) + } + return client.Mount("unix", filepath.Join(ns, "agent")) +} + +// --- Session types --- + +type Session struct { + ID string `json:"id"` + Alias string `json:"alias"` + State string `json:"state"` + PID string `json:"pid"` + CWD string `json:"cwd"` + Backend string `json:"backend"` +} + +// --- Session handlers --- + +func handleSessions(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.Method { + case "GET": + listSessions(w, r) + case "POST": + createSession(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func listSessions(w http.ResponseWriter, r *http.Request) { + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + fid, err := fs.Open("list", plan9.OREAD) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer fid.Close() + + data, err := io.ReadAll(fid) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var sessions []Session + for _, line := range strings.Split(string(data), "\n") { + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 5 { + continue + } + sess := Session{ + ID: fields[0], + Alias: fields[1], + State: fields[2], + PID: fields[3], + CWD: fields[4], + } + + if backendFid, err := fs.Open(filepath.Join(sess.ID, "backend"), plan9.OREAD); err == nil { + if backendData, err := io.ReadAll(backendFid); err == nil { + sess.Backend = strings.TrimSpace(string(backendData)) + } + backendFid.Close() + } + + sessions = append(sessions, sess) + } + + json.NewEncoder(w).Encode(sessions) +} + +func createSession(w http.ResponseWriter, r *http.Request) { + var req struct { + Backend string `json:"backend"` + CWD string `json:"cwd"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.Backend != "kiro-cli" && req.Backend != "claude" { + http.Error(w, "Invalid backend", http.StatusBadRequest) + return + } + + if !filepath.IsAbs(req.CWD) { + http.Error(w, "Path must be absolute", http.StatusBadRequest) + return + } + if _, err := os.Stat(req.CWD); err != nil { + http.Error(w, "Path does not exist", http.StatusBadRequest) + return + } + + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + fid, err := fs.Open("ctl", plan9.OWRITE) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer fid.Close() + + if _, err := fid.Write([]byte(fmt.Sprintf("new %s %s", req.Backend, req.CWD))); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) +} + +func handleSession(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/session/"), "/") + if len(parts) < 1 { + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + + id := parts[0] + action := "" + if len(parts) > 1 { + action = parts[1] + } + + switch r.Method { + case "GET": + if action == "" { + getSession(w, r, id) + } else { + getSessionFile(w, r, id, action) + } + case "POST": + switch action { + case "prompt": + sendPrompt(w, r, id) + case "prompt-direct": + sendPromptDirect(w, r, id) + case "ctl": + controlSession(w, r, id) + default: + http.Error(w, "Invalid action", http.StatusBadRequest) + } + case "PUT": + if action == "alias" || action == "context" { + updateSessionFile(w, r, id, action) + } else { + http.Error(w, "Invalid action", http.StatusBadRequest) + } + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func getSession(w http.ResponseWriter, r *http.Request, id string) { + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + sess := Session{ID: id} + + readField := func(name string) string { + fid, err := fs.Open(filepath.Join(id, name), plan9.OREAD) + if err != nil { + return "" + } + defer fid.Close() + data, _ := io.ReadAll(fid) + return strings.TrimSpace(string(data)) + } + + sess.State = readField("state") + sess.Alias = readField("alias") + sess.Backend = readField("backend") + sess.PID = readField("pid") + sess.CWD = readField("cwd") + + json.NewEncoder(w).Encode(sess) +} + +func getSessionTmux(id string) string { + fs, err := connect() + if err != nil { + return "" + } + defer fs.Close() + + fid, err := fs.Open(filepath.Join(id, "tmux"), plan9.OREAD) + if err != nil { + return "" + } + defer fid.Close() + + data, _ := io.ReadAll(fid) + return strings.TrimSpace(string(data)) +} + +func getSessionFile(w http.ResponseWriter, r *http.Request, id, file string) { + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + fid, err := fs.Open(filepath.Join(id, file), plan9.OREAD) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer fid.Close() + + data, err := io.ReadAll(fid) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(map[string]string{"content": string(data)}) +} + +func sendPrompt(w http.ResponseWriter, r *http.Request, id string) { + var req struct { + Prompt string `json:"prompt"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.Prompt) == "" { + http.Error(w, "Prompt cannot be empty", http.StatusBadRequest) + return + } + + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + msg := map[string]interface{}{ + "to": id, + "type": "PROMPT_REQUEST", + "subject": "User prompt", + "body": req.Prompt, + } + msgJSON, err := json.Marshal(msg) + if err != nil { + http.Error(w, fmt.Sprintf("failed to marshal message: %v", err), http.StatusInternalServerError) + return + } + + fid, err := fs.Open("user/mail", plan9.OWRITE) + if err != nil { + http.Error(w, fmt.Sprintf("failed to open mail file: %v", err), http.StatusInternalServerError) + return + } + defer fid.Close() + + if _, err := fid.Write(msgJSON); err != nil { + http.Error(w, fmt.Sprintf("failed to write message: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func sendPromptDirect(w http.ResponseWriter, r *http.Request, id string) { + var req struct { + Prompt string `json:"prompt"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.Prompt) == "" { + http.Error(w, "Prompt cannot be empty", http.StatusBadRequest) + return + } + + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + fid, err := fs.Open(filepath.Join(id, "in"), plan9.OWRITE) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer fid.Close() + + if _, err := fid.Write([]byte(req.Prompt)); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func controlSession(w http.ResponseWriter, r *http.Request, id string) { + var req struct { + Command string `json:"command"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + validCmds := map[string]bool{"stop": true, "restart": true, "kill": true, "refresh": true, "attach": true} + if !validCmds[req.Command] { + http.Error(w, "Invalid command", http.StatusBadRequest) + return + } + + if req.Command == "attach" { + tmuxTarget := getSessionTmux(id) + if tmuxTarget == "" { + http.Error(w, "Session does not support attach", http.StatusBadRequest) + return + } + cmd := exec.Command(terminalCommand, "-e", "tmux", "attach", "-t", tmuxTarget) + if err := cmd.Start(); err != nil { + http.Error(w, "Failed to launch terminal: "+err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + return + } + + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + fid, err := fs.Open(filepath.Join(id, "ctl"), plan9.OWRITE) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer fid.Close() + + if _, err := fid.Write([]byte(req.Command)); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func updateSessionFile(w http.ResponseWriter, r *http.Request, id, file string) { + var req struct { + Content string `json:"content"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + fid, err := fs.Open(filepath.Join(id, file), plan9.OWRITE) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer fid.Close() + + if _, err := fid.Write([]byte(req.Content)); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +// --- Message types --- + +type Message struct { + ID string `json:"id"` + From string `json:"from"` + To string `json:"to"` + Type string `json:"type"` + Subject string `json:"subject"` + Body string `json:"body"` +} + +type InboxMessage struct { + Message + Filename string `json:"filename"` + Timestamp int64 `json:"timestamp"` +} + +// --- Inbox handlers --- + +func handleInbox(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + messages := readMailbox(fs, "user/inbox") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(messages) +} + +func handleInboxCount(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + fid, err := fs.Open("user/inbox", plan9.OREAD) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer fid.Close() + + count := 0 + for { + dirs, err := fid.Dirread() + if err != nil || len(dirs) == 0 { + break + } + for _, d := range dirs { + if strings.HasSuffix(d.Name, ".json") { + count++ + } + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]int{"count": count}) +} + +func handleInboxReply(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + To string `json:"to"` + Type string `json:"type"` + Subject string `json:"subject"` + Body string `json:"body"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + msg := map[string]interface{}{ + "to": req.To, + "type": req.Type, + "subject": req.Subject, + "body": req.Body, + } + msgJSON, err := json.Marshal(msg) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + fid, err := fs.Open("user/mail", plan9.OWRITE) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer fid.Close() + + if _, err := fid.Write(msgJSON); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "sent"}) +} + +func handleInboxArchive(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Filename string `json:"filename"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + fid, err := fs.Open("user/ctl", plan9.OWRITE) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer fid.Close() + + if _, err := fid.Write([]byte(fmt.Sprintf("complete %s", req.Filename))); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "archived"}) +} + +func handleArchive(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + messages := readMailbox(fs, "user/completed") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(messages) +} + +// readMailbox reads all .json messages from a 9P directory +func readMailbox(fs *client.Fsys, path string) []InboxMessage { + fid, err := fs.Open(path, plan9.OREAD) + if err != nil { + return []InboxMessage{} + } + defer fid.Close() + + messages := make([]InboxMessage, 0) + for { + dirs, err := fid.Dirread() + if err != nil || len(dirs) == 0 { + break + } + for _, d := range dirs { + if !strings.HasSuffix(d.Name, ".json") { + continue + } + + msgFid, err := fs.Open(filepath.Join(path, d.Name), plan9.OREAD) + if err != nil { + continue + } + data, err := io.ReadAll(msgFid) + msgFid.Close() + if err != nil { + continue + } + + var msg Message + if err := json.Unmarshal(data, &msg); err != nil { + continue + } + + var ts int64 + parts := strings.Split(strings.TrimSuffix(d.Name, ".json"), "-") + if len(parts) == 2 { + fmt.Sscanf(parts[1], "%d", &ts) + } + + messages = append(messages, InboxMessage{ + Message: msg, + Filename: d.Name, + Timestamp: ts, + }) + } + } + return messages +} + +// --- Beads handlers --- + +func handleBeads(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + fid, err := fs.Open("beads/list", plan9.OREAD) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer fid.Close() + + data, err := io.ReadAll(fid) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(data) +} + +func handleBeadsReady(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + fid, err := fs.Open("beads/ready", plan9.OREAD) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer fid.Close() + + data, err := io.ReadAll(fid) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(data) +} + +func handleBeadsCtl(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Command string `json:"command"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + fid, err := fs.Open("beads/ctl", plan9.OWRITE) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer fid.Close() + + if _, err := fid.Write([]byte(req.Command)); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func handleBeadDetail(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimPrefix(r.URL.Path, "/api/beads/") + if id == "" { + http.Error(w, "Invalid bead ID", http.StatusBadRequest) + return + } + + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + fid, err := fs.Open(filepath.Join("beads", id, "json"), plan9.OREAD) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer fid.Close() + + data, err := io.ReadAll(fid) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(data) +} + +// --- Daemon status handler --- + +func handleDaemonStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + fs, err := connect() + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "running": false, + "error": err.Error(), + }) + return + } + defer fs.Close() + + fid, err := fs.Open("list", plan9.OREAD) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "running": true, + "session_count": 0, + }) + return + } + defer fid.Close() + + data, _ := io.ReadAll(fid) + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + count := 0 + for _, line := range lines { + if line != "" { + count++ + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "running": true, + "session_count": count, + }) +} diff --git a/mkfile b/mkfile index 3d9ac71..6d02159 100644 --- a/mkfile +++ b/mkfile @@ -7,6 +7,7 @@ build:V: go build -o $INSTALL_PATH/Assist ./cmd/Assist go build -o $INSTALL_PATH/anvillm ./cmd/anvillm go build -o $INSTALL_PATH/anvilweb ./cmd/anvilweb + go build -o $INSTALL_PATH/anvilwebgw ./cmd/anvilwebgw go build -o $INSTALL_PATH/anvilmcp ./cmd/anvilmcp cp scripts/Alog $INSTALL_PATH/Alog chmod 0755 $INSTALL_PATH/Alog @@ -37,4 +38,5 @@ install:V: build clean:V: rm -f $INSTALL_PATH/anvilsrv + rm -f $INSTALL_PATH/anvilwebgw rm -f $INSTALL_PATH/Assist