diff --git a/.gitignore b/.gitignore index 17e32a5..600a38a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ Prompt Q-Reset Q-Login /anvillm +anvilweb .claude diff --git a/README.md b/README.md index 580e987..d9eb184 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,14 @@ AnviLLM consists of: - **Assist**: Acme UI client (auto-starts daemon if needed) - **anvillm.el**: For our emacs friends ;) - **anvillm**: Terminal UI (curses-based) for everyone else +- **anvilweb**: Web interface for browser-based access This separation allows: - Multiple clients to connect to the same sessions - Server survives client crashes/restarts - Scriptable workflows via 9P without UI dependencies - Service management (systemd/runit integration) -- Choice of interface: Acme, terminal, or Emacs +- Choice of interface: Acme, terminal, Emacs, or web browser ## Requirements @@ -161,6 +162,43 @@ Copy anvillm.el to ~/.emacs.d/lisp/ or wherever your elisp files live. Then, add The Emacs interface uses the `9p` command from plan9port to interact with the filesystem. +### Web Interface (anvilweb) + +Run `anvilweb` for a browser-based interface: + +```sh +anvilweb # starts on :8080 +anvilweb -addr :3000 # custom port +``` + +**Features:** +- Start new sessions (Kiro/Claude) +- View all sessions with live state updates +- Send prompts, edit context and aliases +- Stop/restart/kill sessions +- Auto-refreshes every 5 seconds + +**Security Warning:** + +⚠️ **The web interface has NO authentication.** Anyone who can reach the server port can: +- View all sessions +- Send prompts to any session +- Start/stop/kill sessions +- Modify session contexts + +**Only run anvilweb on localhost or trusted networks.** For remote access, use SSH port forwarding: + +```sh +# On remote machine +anvilweb -addr localhost:8080 + +# On local machine +ssh -L 8080:localhost:8080 user@remote +# Then browse to http://localhost:8080 +``` + +Or use a reverse proxy with authentication (nginx, caddy, etc.). + ### Acme Interface (Assist) Type `Assist` in Acme and middle-click to open the `/AnviLLM/` session manager. @@ -344,6 +382,8 @@ See `scripts/DevReview` and `scripts/Planning` for complete examples. | 9P not working | Verify plan9port: `9p ls agent` | | Stale PID file | `anvilsrv stop` cleans up automatically | | Daemon won't stop | Check logs with `anvilsrv fgstart` | +| anvilweb can't connect | Ensure anvilsrv is running; check namespace matches | +| anvilweb port in use | Use `-addr :PORT` to specify different port | **Debugging**: Run `anvilsrv fgstart` to see daemon logs in foreground. diff --git a/cmd/anvilweb/main.go b/cmd/anvilweb/main.go new file mode 100644 index 0000000..ba99110 --- /dev/null +++ b/cmd/anvilweb/main.go @@ -0,0 +1,402 @@ +package main + +import ( + "embed" + "encoding/json" + "flag" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "9fans.net/go/plan9" + "9fans.net/go/plan9/client" +) + +//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") +) + +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")) + } + + 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) + + 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 == "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 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() + + 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 + } + + // Validate command + validCmds := map[string]bool{"stop": true, "restart": true, "kill": true, "refresh": true} + if !validCmds[req.Command] { + http.Error(w, "Invalid command", 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, "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) +} diff --git a/cmd/anvilweb/mkfile b/cmd/anvilweb/mkfile new file mode 100644 index 0000000..00a4cc5 --- /dev/null +++ b/cmd/anvilweb/mkfile @@ -0,0 +1,2 @@ +anvilweb:V: + cd cmd/anvilweb && go build -o $home/bin/anvilweb diff --git a/cmd/anvilweb/static/index.html b/cmd/anvilweb/static/index.html new file mode 100644 index 0000000..337aecc --- /dev/null +++ b/cmd/anvilweb/static/index.html @@ -0,0 +1,336 @@ + + + + + + AnviLLM Web + + + + +
+

AnviLLM

+

Web interface for LLM session management

+
+ +
+
+

New Session

+
+
+ + +
+ +
+
+ +
+
+

Sessions

+ +
+
+
+ + +
+
+ +

Send Prompt

+
+
+ +
+ + +
+
+
+
+ + +
+
+ +

Edit Context

+
+
+ +
+ + +
+
+
+
+ + +
+
+ +

Set Alias

+
+
+ + +
+
+
+
+ + + + diff --git a/mkfile b/mkfile index e6ff491..bd4ef68 100644 --- a/mkfile +++ b/mkfile @@ -6,6 +6,7 @@ build:V: go build -o $INSTALL_PATH/anvilsrv ./cmd/anvilsrv go build -o $INSTALL_PATH/Assist ./cmd/Assist go build -o $INSTALL_PATH/anvillm ./cmd/anvillm + go build -o $INSTALL_PATH/anvilweb ./cmd/anvilweb mkdir -p $INSTALL_PATH/Workflows cp scripts/DevReview $INSTALL_PATH/Workflows/DevReview chmod 0755 $INSTALL_PATH/Workflows/DevReview