This repository has been archived on 2026-06-13. You can view files and clone it, but cannot push or open issues or pull requests.
anvillm/cmd/anvilweb/main.go

772 lines
17 KiB
Go

package main
import (
"embed"
"encoding/json"
"flag"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"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")
)
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)
http.HandleFunc("/api/log", handleLogStream)
http.HandleFunc("/api/inbox", handleInbox)
http.HandleFunc("/api/inbox/count", handleInboxCount)
http.HandleFunc("/api/inbox/reply", handleInboxReply)
http.HandleFunc("/api/inbox/archive", handleInboxArchive)
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",
"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)
}
func handleLogStream(w http.ResponseWriter, r *http.Request) {
// Stream the centralized audit log
// Set headers for SSE
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
fs, err := connect()
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
defer fs.Close()
// Open audit log file for streaming
fid, err := fs.Open("audit", plan9.OREAD)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer fid.Close()
// Flush immediately to establish connection
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
// Stream log data
buf := make([]byte, 4096)
for {
n, err := fid.Read(buf)
if n > 0 {
// Escape data for SSE format - each line must be prefixed with "data: "
data := string(buf[:n])
lines := strings.Split(data, "\n")
for _, line := range lines {
if line != "" || len(lines) > 1 {
fmt.Fprintf(w, "data: %s\n", line)
}
}
fmt.Fprintf(w, "\n")
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
}
if err != nil {
// Connection closed or error
break
}
// Check if client disconnected
select {
case <-r.Context().Done():
return
default:
}
}
}
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"})
}