Add web interface (anvilweb)

- Minimal Go web server with REST API for session management
- Single-page UI using Pico CSS (12KB, classless framework)
- Features: create sessions, send prompts, edit context/alias, control sessions
- Security: input validation, XSS prevention, path validation
- Auto-refresh every 5 seconds
- Documented security model (no authentication, localhost/SSH tunneling recommended)
This commit is contained in:
Levi Neely 2026-02-14 17:02:43 +01:00
parent c367b05a2e
commit 38f3a8e765
6 changed files with 783 additions and 1 deletions

1
.gitignore vendored
View File

@ -3,4 +3,5 @@ Prompt
Q-Reset
Q-Login
/anvillm
anvilweb
.claude

View File

@ -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.

402
cmd/anvilweb/main.go Normal file
View File

@ -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)
}

2
cmd/anvilweb/mkfile Normal file
View File

@ -0,0 +1,2 @@
anvilweb:V:
cd cmd/anvilweb && go build -o $home/bin/anvilweb

View File

@ -0,0 +1,336 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AnviLLM Web</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<style>
body { padding: 1rem; }
.session-card { margin-bottom: 1rem; }
.state-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 600;
}
.state-idle { background: var(--pico-primary-background); color: var(--pico-primary-inverse); }
.state-running { background: var(--pico-color-amber-550); color: white; }
.state-stopped { background: var(--pico-muted-color); color: white; }
.state-error { background: var(--pico-color-red-550); color: white; }
.session-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.session-actions button { margin: 0; padding: 0.5rem 1rem; }
#prompt-area { min-height: 100px; }
.hidden { display: none; }
</style>
</head>
<body>
<header>
<h1>AnviLLM</h1>
<p>Web interface for LLM session management</p>
</header>
<main>
<section>
<h2>New Session</h2>
<form id="new-session-form">
<div class="grid">
<label>
Backend
<select name="backend" required>
<option value="kiro-cli">Kiro</option>
<option value="claude">Claude</option>
</select>
</label>
<label>
Working Directory
<input type="text" name="cwd" placeholder="/path/to/project" required>
</label>
</div>
<button type="submit">Start Session</button>
</form>
</section>
<section>
<div style="display: flex; justify-content: space-between; align-items: center;">
<h2>Sessions</h2>
<button onclick="loadSessions()">Refresh</button>
</div>
<div id="sessions"></div>
</section>
<dialog id="prompt-dialog">
<article>
<header>
<button aria-label="Close" rel="prev" onclick="closePromptDialog()"></button>
<h3>Send Prompt</h3>
</header>
<form id="prompt-form">
<label>
Prompt
<textarea id="prompt-area" name="prompt" required></textarea>
</label>
<footer>
<button type="button" onclick="closePromptDialog()" class="secondary">Cancel</button>
<button type="submit">Send</button>
</footer>
</form>
</article>
</dialog>
<dialog id="context-dialog">
<article>
<header>
<button aria-label="Close" rel="prev" onclick="closeContextDialog()"></button>
<h3>Edit Context</h3>
</header>
<form id="context-form">
<label>
Context (prepended to all prompts)
<textarea id="context-area" name="context" rows="10"></textarea>
</label>
<footer>
<button type="button" onclick="closeContextDialog()" class="secondary">Cancel</button>
<button type="submit">Save</button>
</footer>
</form>
</article>
</dialog>
<dialog id="alias-dialog">
<article>
<header>
<button aria-label="Close" rel="prev" onclick="closeAliasDialog()"></button>
<h3>Set Alias</h3>
</header>
<form id="alias-form">
<label>
Alias
<input type="text" id="alias-input" name="alias" required>
</label>
<footer>
<button type="button" onclick="closeAliasDialog()" class="secondary">Cancel</button>
<button type="submit">Save</button>
</footer>
</form>
</article>
</dialog>
</main>
<script>
let currentSessionId = null;
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function loadSessions() {
try {
const res = await fetch('/api/sessions');
const sessions = await res.json();
const container = document.getElementById('sessions');
if (sessions.length === 0) {
container.innerHTML = '<p>No sessions running</p>';
return;
}
container.innerHTML = sessions.map(s => {
const alias = escapeHtml(s.alias || s.id);
const backend = escapeHtml(s.backend || 'unknown');
const cwd = escapeHtml(s.cwd);
const pid = escapeHtml(s.pid);
const state = escapeHtml(s.state);
return `
<article class="session-card">
<header>
<strong>${alias}</strong>
<span class="state-badge state-${state}">${state}</span>
</header>
<p>
<small>
<strong>Backend:</strong> ${backend}<br>
<strong>CWD:</strong> ${cwd}<br>
<strong>PID:</strong> ${pid}
</small>
</p>
<div class="session-actions">
<button onclick="openPromptDialog('${escapeHtml(s.id)}')" ${s.state !== 'idle' ? 'disabled' : ''}>Prompt</button>
<button onclick="openContextDialog('${escapeHtml(s.id)}')" class="secondary">Context</button>
<button onclick="openAliasDialog('${escapeHtml(s.id)}')" class="secondary">Alias</button>
<button onclick="controlSession('${escapeHtml(s.id)}', 'stop')" ${s.state === 'stopped' ? 'disabled' : ''} class="secondary">Stop</button>
<button onclick="controlSession('${escapeHtml(s.id)}', 'restart')" ${s.state !== 'stopped' ? 'disabled' : ''} class="secondary">Restart</button>
<button onclick="controlSession('${escapeHtml(s.id)}', 'kill')" class="contrast">Kill</button>
</div>
</article>
`}).join('');
} catch (err) {
console.error('Failed to load sessions:', err);
document.getElementById('sessions').innerHTML = '<p>Failed to connect to server</p>';
}
}
document.getElementById('new-session-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const data = new FormData(form);
try {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
backend: data.get('backend'),
cwd: data.get('cwd')
})
});
if (res.ok) {
form.reset();
setTimeout(loadSessions, 500);
} else {
alert('Failed to create session');
}
} catch (err) {
alert('Error: ' + err.message);
}
});
function openPromptDialog(id) {
currentSessionId = id;
document.getElementById('prompt-dialog').showModal();
document.getElementById('prompt-area').focus();
}
function closePromptDialog() {
document.getElementById('prompt-dialog').close();
document.getElementById('prompt-form').reset();
}
document.getElementById('prompt-form').addEventListener('submit', async (e) => {
e.preventDefault();
const prompt = document.getElementById('prompt-area').value;
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');
}
} catch (err) {
alert('Error: ' + err.message);
}
});
async function openContextDialog(id) {
currentSessionId = id;
try {
const res = await fetch(`/api/session/${id}/context`);
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);
}
}
function closeContextDialog() {
document.getElementById('context-dialog').close();
}
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');
}
} catch (err) {
alert('Error: ' + err.message);
}
});
async function openAliasDialog(id) {
currentSessionId = id;
try {
const res = await fetch(`/api/session/${id}/alias`);
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);
}
}
function closeAliasDialog() {
document.getElementById('alias-dialog').close();
}
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');
}
} catch (err) {
alert('Error: ' + err.message);
}
});
async function controlSession(id, cmd) {
try {
const res = await fetch(`/api/session/${id}/ctl`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: cmd })
});
if (res.ok) {
setTimeout(loadSessions, 500);
} else {
alert(`Failed to ${cmd} session`);
}
} catch (err) {
alert('Error: ' + err.message);
}
}
// Load sessions on page load
loadSessions();
// Auto-refresh every 5 seconds
setInterval(loadSessions, 5000);
</script>
</body>
</html>

1
mkfile
View File

@ -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