diff --git a/.gitignore b/.gitignore
index 0ce9df2..e508e78 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,13 +2,10 @@ Q
Prompt
Q-Reset
Q-Login
-/anvillm
-/anvilweb
/anvilmcp
.claude
.kiro
*.elc
/Assist
/anvilsrv
-/anvilwebgw
*.test
diff --git a/README.md b/README.md
index d158bfe..0faaf05 100644
--- a/README.md
+++ b/README.md
@@ -6,8 +6,8 @@ LLM orchestrator using 9P — scriptable, multi-backend, crash-resilient

-**Core:** anvilsrv (9P daemon), anvilmcp (MCP server), anvilwebgw (API proxy)
-**Clients:** Assist (Acme), anvillm.el (Emacs), anvillm (TUI), anvilweb (web)
+**Core:** anvilsrv (9P daemon), anvilmcp (MCP server)
+**Frontends:** Any 9P-speaking program — currently Assist (Acme)
**Benefits:** Shared sessions, crash recovery, scriptable via 9P, cross-backend agent communication
## Why 9P?
@@ -56,30 +56,19 @@ NAMESPACE=/tmp/ns.$USER.:1 anvilsrv start
NAMESPACE=/tmp/ns.$USER.:1 Assist
```
-### Clients
+### Frontends
-**TUI:** `anvillm` — Keys: `s` start | `p` prompt | `t` stop | `R` restart | `K` kill | `a` alias | `r` refresh | `d` daemon | `?` help | `q` quit
+Any program that speaks 9P can be a frontend. The 9P filesystem exposes session management, state, messaging, and configuration as plain files — so building a new frontend is just reading and writing files. Past prototypes have included a TUI, a web UI with HTTP gateway, and an Emacs major mode, all built against the same API. The current frontend is:
-**Emacs:** `(require 'anvillm)` then `M-x anvillm` — Keys: `s` start | `p` prompt | `P` prompt (minibuffer) | `t` stop | `R` restart | `K` kill | `a` alias | `r`/`g` refresh | `d` daemon | `?` help | `q` quit
+- **Assist** — an Acme client (middle-click `Assist` to launch)
-**Web:** `anvilweb` (port :8080) — ⚠️ NO auth, localhost only — Remote: `ssh -L 8080:localhost:8080 user@remote`
+For web frontends that can't speak 9P directly, `anvilwebgw` bridges HTTP to 9P.
-**Acme:** Type `Assist` and middle-click
+The `mcptools` scripts (served via 9P at `tools/`) also provide convenient building blocks for frontends:
-**Tag commands:** `Get Attach Stop Restart Kill Alias Context Daemon Inbox Archive`
-
-**Interaction:** Right-click ID for prompt window, 2-1 chord for fire-and-forget
-
-| Command | Action |
-|---------|--------|
-| `Get` | Refresh session list |
-| `Kiro
` / `Claude ` / `Ollama ` | Start session |
-| `Attach ` | Open tmux |
-| `Stop ` / `Restart ` / `Kill ` | Control session |
-| `Alias ` | Name session |
-| `Context ` | Edit context |
-| `Daemon` | Manage daemon |
-| `Inbox [id]` / `Archive [id]` | View messages |
+```sh
+bash <(9p read tools/)
+```
## Configuration
@@ -307,7 +296,7 @@ Bot templates provide pre-configured agents for common tasks. Templates are shel
./bot-templates/Conductor kiro /path/to/project
```
-Templates set up context, roles, and initial configuration. Interact via any client (Assist, TUI, Emacs, Web) or directly via 9P mailbox.
+Templates set up context, roles, and initial configuration. Interact via Assist or directly via 9P mailbox.
**Creating Templates:** Shell scripts that write to `anvillm/ctl` and configure `context`, `role`, `tasks`. See existing templates for examples.
@@ -321,7 +310,7 @@ Conductor receives the top-level bead ID, analyzes dependencies, and spawns agen
### Usage
-Examples use raw 9P calls. These actions can also be performed from any front-end (Assist, TUI, Emacs, Web).
+Examples use raw 9P calls. These actions can also be performed from Assist.
**1. Taskmaster: Create tasks from project plan**
@@ -348,7 +337,7 @@ echo '{"to":"'$CONDUCTOR_ID'","type":"WORK_REQUEST","subject":"Execute","body":"
Conductor analyzes dependencies, spawns specialized bots, and delegates work in parallel.
-**Monitoring:** Use any front-end (Assist, TUI, Emacs, Web). For custom integrations, see `EVENTS.md`.
+**Monitoring:** Use Assist or any 9P client. For custom integrations, see `EVENTS.md`.
## MCP Integration
@@ -382,6 +371,4 @@ See [Code Execution User Guide](docs/code-execution-user-guide.md) for details.
| 9P not working | `9p ls agent` |
| Stale PID | `anvilsrv stop` auto-cleans |
| Daemon won't stop | `anvilsrv fgstart` for logs |
-| anvilweb issues | Check anvilsrv running, namespace matches |
-
See Configuration section for environment variables.
diff --git a/cmd/anvillm/main.go b/cmd/anvillm/main.go
deleted file mode 100644
index 4206c22..0000000
--- a/cmd/anvillm/main.go
+++ /dev/null
@@ -1,1980 +0,0 @@
-// anvillm - Terminal UI for AnviLLM
-package main
-
-import (
- "anvillm/internal/logging"
- "encoding/json"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "regexp"
- "strings"
- "time"
-
- "9fans.net/go/plan9"
- "9fans.net/go/plan9/client"
- "github.com/gdamore/tcell/v2"
- "github.com/rivo/tview"
- "go.uber.org/zap"
-)
-
-var (
- fs *client.Fsys
- app *tview.Application
-
- // UI components
- sessionList *tview.Table
- statusBar *tview.TextView
- promptInput *tview.InputField
- contextView *tview.TextView
- pages *tview.Pages
-
- // Current beads mount
- currentMount = "default"
-)
-
-type SessionInfo struct {
- ID string
- Alias string
- Backend string
- State string
- Pid int
- Cwd string
- Role string // Role assigned to this session (empty = not set)
-}
-
-func main() {
- if err := logging.Init(); err != nil {
- fmt.Fprintf(os.Stderr, "Failed to initialize logging: %v\n", err)
- os.Exit(1)
- }
- defer logging.Logger().Sync()
-
- // Connect to anvilsrv
- var err error
- fs, err = connectToServer()
- if err != nil {
- logging.Logger().Fatal("failed to connect to anvilsrv", zap.Error(err))
- }
- defer fs.Close()
-
- // Create application
- app = tview.NewApplication()
-
- // Create UI components
- setupUI()
-
- // Start refresh ticker
- go refreshLoop()
-
- // Run application
- if err := app.SetRoot(pages, true).EnableMouse(true).Run(); err != nil {
- logging.Logger().Fatal("application error", zap.Error(err))
- }
-}
-
-func setupUI() {
- // Session list table
- sessionList = tview.NewTable().
- SetBorders(false).
- SetSelectable(true, false).
- SetFixed(1, 0) // Fix header row
- sessionList.SetBorder(true).SetTitle(" Sessions ").SetTitleAlign(tview.AlignLeft)
-
- // Status bar
- statusBar = tview.NewTextView().
- SetDynamicColors(true).
- SetTextAlign(tview.AlignLeft)
- statusBar.SetBorder(true).SetTitle(" Status ")
- updateStatus("Connected to anvilsrv")
-
- // Main layout
- mainLayout := tview.NewFlex().
- SetDirection(tview.FlexRow).
- AddItem(sessionList, 0, 1, true).
- AddItem(statusBar, 3, 0, false)
-
- // Create pages container
- pages = tview.NewPages()
- pages.AddPage("main", mainLayout, true, true)
-
- // Key bindings for main view
- sessionList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- row, col := sessionList.GetSelection()
-
- switch event.Key() {
- case tcell.KeyRune:
- switch event.Rune() {
- case 'q':
- app.Stop()
- return nil
- case 'r':
- refreshSessions()
- return nil
- case 's':
- showBackendSelectionMenu()
- return nil
- case ' ':
- showPromptDialog()
- return nil
- case 'T':
- stopSelectedSession()
- return nil
- case 'R':
- restartSelectedSession()
- return nil
- case 'K':
- killSelectedSession()
- return nil
- case 'A':
- showAliasDialog()
- return nil
- case 'c':
- showContextEditor()
- return nil
- case 'i':
- showInbox()
- return nil
- case 'a':
- showArchive()
- return nil
- case 't':
- attachToSession()
- return nil
- case 'b':
- showBeads()
- return nil
- case 'm':
- showMountManager()
- return nil
- case 'd':
- showDaemonStatus()
- return nil
- case '?':
- showHelp()
- return nil
- // Vim keybindings
- case 'j', 'n':
- if row < sessionList.GetRowCount()-1 {
- sessionList.Select(row+1, col)
- }
- return nil
- case 'k', 'p':
- if row > 1 { // Don't go above first data row
- sessionList.Select(row-1, col)
- }
- return nil
- }
- // Emacs keybindings
- case tcell.KeyCtrlN:
- if row < sessionList.GetRowCount()-1 {
- sessionList.Select(row+1, col)
- }
- return nil
- case tcell.KeyCtrlP:
- if row > 1 {
- sessionList.Select(row-1, col)
- }
- return nil
- }
- return event
- })
-
- // Initial refresh
- refreshSessions()
-}
-
-func connectToServer() (*client.Fsys, error) {
- return client.MountService("anvillm")
-}
-
-func refreshLoop() {
- ticker := time.NewTicker(2 * time.Second)
- defer ticker.Stop()
-
- for range ticker.C {
- app.QueueUpdateDraw(func() {
- refreshSessions()
- })
- }
-}
-
-func refreshSessions() {
- sessions, err := listSessions()
- if err != nil {
- updateStatus(fmt.Sprintf("[red]Error: %v", err))
- return
- }
-
- sessionList.Clear()
-
- // Header with blue background and white text
- headerStyle := tcell.StyleDefault.
- Background(tcell.ColorBlue).
- Foreground(tcell.ColorWhite).
- Bold(true)
-
- sessionList.SetCell(0, 0, tview.NewTableCell(" ID ").
- SetStyle(headerStyle).
- SetSelectable(false).
- SetExpansion(1))
- sessionList.SetCell(0, 1, tview.NewTableCell(" Alias ").
- SetStyle(headerStyle).
- SetSelectable(false).
- SetExpansion(1))
- sessionList.SetCell(0, 2, tview.NewTableCell(" Backend ").
- SetStyle(headerStyle).
- SetSelectable(false).
- SetExpansion(1))
- sessionList.SetCell(0, 3, tview.NewTableCell(" Model ").
- SetStyle(headerStyle).
- SetSelectable(false).
- SetExpansion(1))
- sessionList.SetCell(0, 4, tview.NewTableCell(" State ").
- SetStyle(headerStyle).
- SetSelectable(false).
- SetExpansion(1))
- sessionList.SetCell(0, 5, tview.NewTableCell(" PID ").
- SetStyle(headerStyle).
- SetSelectable(false).
- SetExpansion(1))
- sessionList.SetCell(0, 6, tview.NewTableCell(" Cwd ").
- SetStyle(headerStyle).
- SetSelectable(false).
- SetExpansion(3))
-
- // Sessions
- for i, sess := range sessions {
- row := i + 1
- alias := sess.Alias
- if alias == "" {
- alias = "-"
- }
-
- stateColor := tcell.ColorWhite
- switch sess.State {
- case "running":
- stateColor = tcell.ColorGreen
- case "idle":
- stateColor = tcell.ColorAqua
- case "stopped":
- stateColor = tcell.ColorYellow
- case "error", "exited":
- stateColor = tcell.ColorRed
- }
-
- role := sess.Role
- if role == "" {
- role = "-"
- }
-
- sessionList.SetCell(row, 0, tview.NewTableCell(" "+sess.ID[:8]+" ").SetExpansion(1))
- sessionList.SetCell(row, 1, tview.NewTableCell(" "+alias+" ").SetExpansion(1))
- sessionList.SetCell(row, 2, tview.NewTableCell(" "+sess.Backend+" ").SetExpansion(1))
- sessionList.SetCell(row, 3, tview.NewTableCell(" "+role+" ").SetExpansion(1))
- sessionList.SetCell(row, 4, tview.NewTableCell(" "+sess.State+" ").SetTextColor(stateColor).SetExpansion(1))
- sessionList.SetCell(row, 5, tview.NewTableCell(fmt.Sprintf(" %d ", sess.Pid)).SetExpansion(1))
- sessionList.SetCell(row, 6, tview.NewTableCell(" "+sess.Cwd+" ").SetExpansion(3))
- }
-
- updateStatus(fmt.Sprintf("Sessions: %d | [yellow]s[white]:start [yellow]space[white]:prompt [yellow]c[white]:context [yellow]i[white]:inbox [yellow]a[white]:archive [yellow]b[white]:beads [yellow]m[white]:mounts [yellow]t[white]:attach [yellow]T[white]:stop [yellow]R[white]:restart [yellow]K[white]:kill [yellow]A[white]:alias [yellow]r[white]:refresh [yellow]?[white]:help [yellow]q[white]:quit", len(sessions)))
-}
-
-func listSessions() ([]*SessionInfo, error) {
- data, err := readFile("list")
- if err != nil {
- return nil, err
- }
-
- lines := strings.Split(string(data), "\n")
- var sessions []*SessionInfo
-
- for _, line := range lines {
- line = strings.TrimSpace(line)
- if line == "" {
- continue
- }
-
- // Parse: id backend state alias role cwd
- fields := strings.Split(line, "\t")
- if len(fields) < 6 {
- continue
- }
-
- sess := &SessionInfo{
- ID: fields[0],
- Backend: fields[1],
- State: fields[2],
- Alias: fields[3],
- Role: fields[4],
- Cwd: fields[5],
- }
- if sess.Alias == "-" {
- sess.Alias = ""
- }
- if sess.Role == "-" {
- sess.Role = ""
- }
-
- sessions = append(sessions, sess)
- }
-
- return sessions, nil
-}
-
-func readFile(path string) ([]byte, error) {
- fid, err := fs.Open(path, plan9.OREAD)
- if err != nil {
- return nil, err
- }
- defer fid.Close()
-
- var buf []byte
- tmp := make([]byte, 8192)
- for {
- n, err := fid.Read(tmp)
- if n > 0 {
- buf = append(buf, tmp[:n]...)
- }
- if err != nil {
- break
- }
- }
- return buf, nil
-}
-
-func readLogFile(path string) ([]byte, error) {
- fid, err := fs.Open(path, plan9.OREAD)
- if err != nil {
- return nil, err
- }
- defer fid.Close()
-
- // Read in chunks, but stop when we get less than requested
- // This prevents blocking on the streaming log file
- var buf []byte
- tmp := make([]byte, 8192)
- for {
- n, err := fid.Read(tmp)
- if n > 0 {
- buf = append(buf, tmp[:n]...)
- }
- // Stop if we got less than buffer size (no more data available)
- // or if there was an error
- if n < len(tmp) || err != nil {
- break
- }
- }
- return buf, nil
-}
-
-func writeFile(path string, data []byte) error {
- fid, err := fs.Open(path, plan9.OWRITE)
- if err != nil {
- return err
- }
- defer fid.Close()
-
- _, err = fid.Write(data)
- return err
-}
-
-func sendPrompt(id, prompt string) error {
- // Detect and wrap bead IDs
- prompt = wrapBeadIDs(prompt)
-
- // Create message JSON
- msg := map[string]interface{}{
- "to": id,
- "type": "PROMPT_REQUEST",
- "subject": "User prompt",
- "body": prompt,
- }
- msgJSON, err := json.Marshal(msg)
- if err != nil {
- return fmt.Errorf("failed to marshal message: %w", err)
- }
-
- // Write to user mail
- path := "user/mail"
-
- fid, err := fs.Open(path, plan9.OWRITE)
- if err != nil {
- return fmt.Errorf("failed to open mail file: %w", err)
- }
- defer fid.Close()
-
- _, err = fid.Write(msgJSON)
- if err != nil {
- return fmt.Errorf("failed to write message: %w", err)
- }
-
- return nil
-}
-
-// inferReplyType returns the appropriate response type for a given request type
-func inferReplyType(msgType string) string {
- switch msgType {
- case "APPROVAL_REQUEST":
- return "APPROVAL_RESPONSE"
- case "REVIEW_REQUEST":
- return "REVIEW_RESPONSE"
- case "QUERY_REQUEST":
- return "QUERY_RESPONSE"
- default:
- return "PROMPT_RESPONSE"
- }
-}
-
-// completeInboxMessage moves a message from inbox to completed archive
-func completeInboxMessage(msgID string) error {
- cmd := fmt.Sprintf("complete %s", msgID)
- return writeFile("user/ctl", []byte(cmd))
-}
-
-// sendReply sends a reply message via user/mail
-func sendReply(to, replyType, subject, body string) error {
- msg := map[string]interface{}{
- "to": to,
- "type": replyType,
- "subject": subject,
- "body": body,
- }
- msgJSON, err := json.Marshal(msg)
- if err != nil {
- return fmt.Errorf("failed to marshal message: %w", err)
- }
- return writeFile("user/mail", msgJSON)
-}
-
-// approveMessage sends an approval response and completes the original message
-func approveMessage(msg map[string]interface{}) error {
- msgID := ""
- if id, ok := msg["id"].(string); ok {
- msgID = id
- }
- from := ""
- if f, ok := msg["from"].(string); ok {
- from = f
- }
- subject := ""
- if s, ok := msg["subject"].(string); ok {
- subject = "Re: " + s
- }
- msgType := ""
- if t, ok := msg["type"].(string); ok {
- msgType = t
- }
-
- replyType := inferReplyType(msgType)
- if err := sendReply(from, replyType, subject, "Approved"); err != nil {
- return err
- }
-
- if msgID != "" {
- return completeInboxMessage(msgID)
- }
- return nil
-}
-
-// rejectMessage sends a rejection response and completes the original message
-func rejectMessage(msg map[string]interface{}, reason string) error {
- msgID := ""
- if id, ok := msg["id"].(string); ok {
- msgID = id
- }
- from := ""
- if f, ok := msg["from"].(string); ok {
- from = f
- }
- subject := ""
- if s, ok := msg["subject"].(string); ok {
- subject = "Re: " + s
- }
- msgType := ""
- if t, ok := msg["type"].(string); ok {
- msgType = t
- }
-
- replyType := inferReplyType(msgType)
- body := "Rejected"
- if reason != "" {
- body = "Rejected: " + reason
- }
- if err := sendReply(from, replyType, subject, body); err != nil {
- return err
- }
-
- if msgID != "" {
- return completeInboxMessage(msgID)
- }
- return nil
-}
-
-// showRejectDialog shows a text input dialog for rejection reason
-func showRejectDialog(msg map[string]interface{}) {
- input := tview.NewInputField().
- SetLabel("Reason: ").
- SetFieldWidth(0).
- SetFieldTextColor(tcell.ColorBlack).
- SetFieldBackgroundColor(tcell.ColorWhite)
-
- input.SetDoneFunc(func(key tcell.Key) {
- if key == tcell.KeyEnter {
- reason := input.GetText()
- if err := rejectMessage(msg, reason); err != nil {
- updateStatus(fmt.Sprintf("[red]Error rejecting message: %v", err))
- } else {
- from := ""
- if f, ok := msg["from"].(string); ok {
- from = f
- }
- updateStatus(fmt.Sprintf("[green]Rejected message from %s", from))
- pages.RemovePage("reject-dialog")
- pages.RemovePage("message")
- if pages.HasPage("inbox") {
- pages.RemovePage("inbox")
- showInbox()
- }
- return
- }
- }
- pages.RemovePage("reject-dialog")
- })
-
- container := tview.NewFlex().
- SetDirection(tview.FlexRow).
- AddItem(input, 1, 0, true)
-
- container.SetBorder(true).
- SetTitle(" Reject Message - Enter Reason (Enter to confirm, Esc to cancel) ").
- SetTitleAlign(tview.AlignLeft).
- SetBorderColor(tcell.ColorRed)
-
- pages.AddPage("reject-dialog", createModal(container, 70, 5), true, true)
-}
-
-func wrapBeadIDs(text string) string {
- // Match bead IDs like bd-5xz or bd-5xz.1
- re := regexp.MustCompile(`\bbd-[a-z0-9]+(?:\.[0-9]+)?\b`)
- return re.ReplaceAllStringFunc(text, func(id string) string {
- // Read bead to get title
- beadPath := filepath.Join("beads", currentMount, id, "json")
- data, err := readFile(beadPath)
- if err != nil {
- return id // Return unwrapped if can't read
- }
-
- var bead map[string]interface{}
- if err := json.Unmarshal(data, &bead); err != nil {
- return id
- }
-
- title := ""
- if t, ok := bead["title"].(string); ok {
- title = t
- }
-
- return fmt.Sprintf("%s (%s)", id, title)
- })
-}
-
-func updateStatus(msg string) {
- statusBar.Clear()
- fmt.Fprintf(statusBar, " %s", msg)
-}
-
-func getSelectedSession() *SessionInfo {
- row, _ := sessionList.GetSelection()
- if row <= 0 {
- return nil
- }
-
- sessions, _ := listSessions()
- if row-1 < len(sessions) {
- return sessions[row-1]
- }
- return nil
-}
-
-func showBackendSelectionMenu() {
- backends := []string{"claude", "kiro-cli"}
-
- list := tview.NewList().
- ShowSecondaryText(false)
- list.SetSelectedBackgroundColor(tcell.ColorWhite)
- list.SetSelectedTextColor(tcell.ColorBlack)
-
- for _, backend := range backends {
- b := backend // capture for closure
- list.AddItem(b, "", 0, func() {
- pages.RemovePage("backend-menu")
- showModelSelectionMenu(b)
- })
- }
-
- list.SetBorder(true).
- SetTitle(" Select Backend ").
- SetTitleAlign(tview.AlignLeft).
- SetBorderColor(tcell.ColorBlue)
-
- list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Key() == tcell.KeyEscape || event.Rune() == 'q' {
- pages.RemovePage("backend-menu")
- return nil
- }
- return event
- })
-
- pages.AddPage("backend-menu", createModal(list, 30, 5), true, true)
-}
-
-// showModelSelectionMenu shows model tier options for the selected backend.
-// For claude: haiku / sonnet / opus / default.
-// For other backends: proceeds directly to directory input with no model override.
-func showModelSelectionMenu(backend string) {
- // Only claude supports model tier selection
- if backend != "claude" {
- showCreateSession(backend, "")
- return
- }
-
- type modelOption struct {
- label string
- model string // empty = backend default
- }
- options := []modelOption{
- {"default (sonnet)", ""},
- {"haiku — fast, cheap, simple tasks", "haiku"},
- {"sonnet — multi-file edits, reasoning", "sonnet"},
- {"opus — novel design, long-horizon planning", "opus"},
- }
-
- list := tview.NewList().
- ShowSecondaryText(false)
- list.SetSelectedBackgroundColor(tcell.ColorWhite)
- list.SetSelectedTextColor(tcell.ColorBlack)
-
- for _, opt := range options {
- o := opt // capture for closure
- list.AddItem(o.label, "", 0, func() {
- pages.RemovePage("model-menu")
- showCreateSession(backend, o.model)
- })
- }
-
- list.SetBorder(true).
- SetTitle(" Select Model ").
- SetTitleAlign(tview.AlignLeft).
- SetBorderColor(tcell.ColorBlue)
-
- list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Key() == tcell.KeyEscape || event.Rune() == 'q' {
- pages.RemovePage("model-menu")
- return nil
- }
- return event
- })
-
- pages.AddPage("model-menu", createModal(list, 50, 8), true, true)
-}
-
-func showCreateSession(backend, model string) {
- input := tview.NewInputField().
- SetLabel("Directory: ").
- SetFieldWidth(0).
- SetFieldTextColor(tcell.ColorBlack).
- SetFieldBackgroundColor(tcell.ColorWhite)
-
- input.SetDoneFunc(func(key tcell.Key) {
- if key == tcell.KeyEnter {
- dir := input.GetText()
- if dir != "" {
- cmd := fmt.Sprintf("new %s %s", backend, dir)
- if model != "" {
- cmd += fmt.Sprintf(" model=%s", model)
- }
- if err := writeFile("ctl", []byte(cmd)); err != nil {
- updateStatus(fmt.Sprintf("[red]Error: %v", err))
- } else {
- modelDesc := model
- if modelDesc == "" {
- modelDesc = "default"
- }
- updateStatus(fmt.Sprintf("[green]Created %s (%s) session in %s", backend, modelDesc, dir))
- refreshSessions()
- }
- }
- }
- pages.RemovePage("input")
- })
-
- title := fmt.Sprintf(" Create %s Session ", backend)
- if model != "" {
- title = fmt.Sprintf(" Create %s (%s) Session ", backend, model)
- }
-
- // Wrap input in a flex container with border
- container := tview.NewFlex().
- SetDirection(tview.FlexRow).
- AddItem(input, 1, 0, true)
-
- container.SetBorder(true).
- SetTitle(title).
- SetTitleAlign(tview.AlignLeft).
- SetBorderColor(tcell.ColorBlue)
-
- pages.AddPage("input", createModal(container, 60, 5), true, true)
-}
-
-func showPromptDialog() {
- sess := getSelectedSession()
- if sess == nil {
- updateStatus("[yellow]No session selected")
- return
- }
-
- input := tview.NewTextArea().
- SetPlaceholder("Enter prompt...")
-
- form := tview.NewForm().
- AddFormItem(input)
- form.SetFieldTextColor(tcell.ColorBlack)
- form.SetButtonTextColor(tcell.ColorBlack)
-
- form.AddButton("Send", func() {
- prompt := input.GetText()
- if prompt != "" {
- if err := sendPrompt(sess.ID, prompt); err != nil {
- updateStatus(fmt.Sprintf("[red]Error: %v", err))
- } else {
- updateStatus(fmt.Sprintf("[green]Sent prompt to %s", sess.ID[:8]))
- }
- }
- pages.RemovePage("prompt")
- })
-
- form.AddButton("Cancel", func() {
- pages.RemovePage("prompt")
- })
-
- form.SetBorder(true).
- SetTitle(fmt.Sprintf(" Send Prompt to %s ", sess.ID[:8])).
- SetTitleAlign(tview.AlignLeft).
- SetBorderColor(tcell.ColorBlue)
-
- pages.AddPage("prompt", createModal(form, 80, 20), true, true)
-}
-
-func showAliasDialog() {
- sess := getSelectedSession()
- if sess == nil {
- updateStatus("[yellow]No session selected")
- return
- }
-
- input := tview.NewInputField().
- SetLabel("Alias: ").
- SetText(sess.Alias).
- SetFieldWidth(0).
- SetFieldTextColor(tcell.ColorBlack).
- SetFieldBackgroundColor(tcell.ColorWhite)
-
- input.SetDoneFunc(func(key tcell.Key) {
- if key == tcell.KeyEnter {
- alias := input.GetText()
- path := filepath.Join(sess.ID, "alias")
- if err := writeFile(path, []byte(alias)); err != nil {
- updateStatus(fmt.Sprintf("[red]Error: %v", err))
- } else {
- updateStatus(fmt.Sprintf("[green]Set alias to '%s'", alias))
- refreshSessions()
- }
- }
- pages.RemovePage("alias")
- })
-
- // Wrap input in a flex container with border
- container := tview.NewFlex().
- SetDirection(tview.FlexRow).
- AddItem(input, 1, 0, true)
-
- container.SetBorder(true).
- SetTitle(fmt.Sprintf(" Set Alias for %s ", sess.ID[:8])).
- SetTitleAlign(tview.AlignLeft).
- SetBorderColor(tcell.ColorBlue)
-
- pages.AddPage("alias", createModal(container, 50, 5), true, true)
-}
-
-func stopSelectedSession() {
- sess := getSelectedSession()
- if sess == nil {
- updateStatus("[yellow]No session selected")
- return
- }
-
- path := filepath.Join(sess.ID, "ctl")
- if err := writeFile(path, []byte("stop")); err != nil {
- updateStatus(fmt.Sprintf("[red]Error: %v", err))
- } else {
- updateStatus(fmt.Sprintf("[green]Stopped session %s", sess.ID[:8]))
- refreshSessions()
- }
-}
-
-func restartSelectedSession() {
- sess := getSelectedSession()
- if sess == nil {
- updateStatus("[yellow]No session selected")
- return
- }
-
- path := filepath.Join(sess.ID, "ctl")
- if err := writeFile(path, []byte("restart")); err != nil {
- updateStatus(fmt.Sprintf("[red]Error: %v", err))
- } else {
- updateStatus(fmt.Sprintf("[green]Restarted session %s", sess.ID[:8]))
- refreshSessions()
- }
-}
-
-func killSelectedSession() {
- sess := getSelectedSession()
- if sess == nil {
- updateStatus("[yellow]No session selected")
- return
- }
-
- path := filepath.Join(sess.ID, "ctl")
- if err := writeFile(path, []byte("kill")); err != nil {
- updateStatus(fmt.Sprintf("[red]Error: %v", err))
- } else {
- updateStatus(fmt.Sprintf("[green]Killed session %s", sess.ID[:8]))
- refreshSessions()
- }
-}
-
-func showDaemonStatus() {
- data, err := readFile("daemon")
- if err != nil {
- updateStatus(fmt.Sprintf("[red]Error reading daemon status: %v", err))
- return
- }
-
- textView := tview.NewTextView().
- SetDynamicColors(true).
- SetText(string(data)).
- SetScrollable(true)
-
- textView.SetBorder(true).SetTitle(" Daemon Status ")
- textView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Rune() == 'q' || event.Key() == tcell.KeyEscape {
- pages.RemovePage("daemon")
- return nil
- }
- return event
- })
-
- pages.AddPage("daemon", createModal(textView, 70, 20), true, true)
-}
-
-func showContextEditor() {
- sess := getSelectedSession()
- if sess == nil {
- updateStatus("[yellow]No session selected")
- return
- }
-
- // Read current context
- contextPath := filepath.Join(sess.ID, "context")
- contextData, err := readFile(contextPath)
- if err != nil {
- updateStatus(fmt.Sprintf("[red]Error reading context: %v", err))
- return
- }
-
- textArea := tview.NewTextArea().
- SetText(string(contextData), true)
-
- form := tview.NewForm().
- AddFormItem(textArea)
- form.SetFieldTextColor(tcell.ColorBlack)
- form.SetButtonTextColor(tcell.ColorBlack)
-
- form.AddButton("Save", func() {
- newContext := textArea.GetText()
- if err := writeFile(contextPath, []byte(newContext)); err != nil {
- updateStatus(fmt.Sprintf("[red]Error saving context: %v", err))
- } else {
- updateStatus(fmt.Sprintf("[green]Context saved for %s", sess.ID[:8]))
- }
- pages.RemovePage("context")
- })
-
- form.AddButton("Cancel", func() {
- pages.RemovePage("context")
- })
-
- form.SetBorder(true).
- SetTitle(fmt.Sprintf(" Edit Context - %s ", sess.ID[:8])).
- SetTitleAlign(tview.AlignLeft).
- SetBorderColor(tcell.ColorBlue)
-
- pages.AddPage("context", createModalDynamic(form, 4, 30), true, true)
-}
-
-func showInbox() {
- // Read inbox directory
- fid, err := fs.Open("user/inbox", plan9.OREAD)
- if err != nil {
- updateStatus(fmt.Sprintf("[red]Error opening inbox: %v", err))
- return
- }
- defer fid.Close()
-
- var messages []map[string]interface{}
- for {
- dirs, err := fid.Dirread()
- if err != nil || len(dirs) == 0 {
- break
- }
- for _, d := range dirs {
- if !strings.HasSuffix(d.Name, ".json") {
- continue
- }
-
- msgPath := filepath.Join("user/inbox", d.Name)
- msgData, err := readFile(msgPath)
- if err != nil {
- continue
- }
-
- var msg map[string]interface{}
- if err := json.Unmarshal(msgData, &msg); err != nil {
- continue
- }
-
- messages = append(messages, msg)
- }
- }
-
- list := tview.NewList().ShowSecondaryText(true)
- list.SetSelectedBackgroundColor(tcell.ColorWhite)
- list.SetSelectedTextColor(tcell.ColorBlack)
-
- for i, msg := range messages {
- idx := i
- from := ""
- if f, ok := msg["from"].(string); ok {
- from = f
- }
- subject := ""
- if s, ok := msg["subject"].(string); ok {
- subject = s
- }
- msgType := ""
- if t, ok := msg["type"].(string); ok {
- msgType = t
- }
-
- list.AddItem(
- fmt.Sprintf("(%s) %s", from, subject),
- msgType,
- 0,
- func() {
- showMessage(messages[idx])
- },
- )
- }
-
- list.SetBorder(true).
- SetTitle(fmt.Sprintf(" Inbox (%d messages) | A:approve | X:reject | Q:close ", len(messages))).
- SetTitleAlign(tview.AlignLeft).
- SetBorderColor(tcell.ColorBlue)
-
- list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Rune() == 'q' || event.Key() == tcell.KeyEscape {
- pages.RemovePage("inbox")
- return nil
- }
- if event.Rune() == 'a' {
- idx := list.GetCurrentItem()
- if idx >= 0 && idx < len(messages) {
- msg := messages[idx]
- if err := approveMessage(msg); err != nil {
- updateStatus(fmt.Sprintf("[red]Error approving message: %v", err))
- } else {
- from := ""
- if f, ok := msg["from"].(string); ok {
- from = f
- }
- updateStatus(fmt.Sprintf("[green]Approved message from %s", from))
- pages.RemovePage("inbox")
- showInbox()
- }
- }
- return nil
- }
- if event.Rune() == 'x' {
- idx := list.GetCurrentItem()
- if idx >= 0 && idx < len(messages) {
- showRejectDialog(messages[idx])
- }
- return nil
- }
- return event
- })
-
- pages.AddPage("inbox", createModalDynamic(list, 8, 16), true, true)
-}
-
-func showArchive() {
- // Read completed directory (archive)
- fid, err := fs.Open("user/completed", plan9.OREAD)
- if err != nil {
- updateStatus(fmt.Sprintf("[red]Error opening archive: %v", err))
- return
- }
- defer fid.Close()
-
- var messages []map[string]interface{}
- for {
- dirs, err := fid.Dirread()
- if err != nil || len(dirs) == 0 {
- break
- }
- for _, d := range dirs {
- if !strings.HasSuffix(d.Name, ".json") {
- continue
- }
-
- msgPath := filepath.Join("user/completed", d.Name)
- msgData, err := readFile(msgPath)
- if err != nil {
- continue
- }
-
- var msg map[string]interface{}
- if err := json.Unmarshal(msgData, &msg); err != nil {
- continue
- }
-
- messages = append(messages, msg)
- }
- }
-
- list := tview.NewList().ShowSecondaryText(true)
- list.SetSelectedBackgroundColor(tcell.ColorWhite)
- list.SetSelectedTextColor(tcell.ColorBlack)
-
- for i, msg := range messages {
- idx := i
- from := ""
- if f, ok := msg["from"].(string); ok {
- from = f
- }
- subject := ""
- if s, ok := msg["subject"].(string); ok {
- subject = s
- }
- msgType := ""
- if t, ok := msg["type"].(string); ok {
- msgType = t
- }
-
- list.AddItem(
- fmt.Sprintf("(%s) %s", from, subject),
- msgType,
- 0,
- func() {
- showMessage(messages[idx])
- },
- )
- }
-
- list.SetBorder(true).
- SetTitle(fmt.Sprintf(" Archive (%d messages) ", len(messages))).
- SetTitleAlign(tview.AlignLeft).
- SetBorderColor(tcell.ColorBlue)
-
- list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Rune() == 'q' || event.Key() == tcell.KeyEscape {
- pages.RemovePage("archive")
- return nil
- }
- return event
- })
-
- pages.AddPage("archive", createModalDynamic(list, 8, 16), true, true)
-}
-
-func attachToSession() {
- sess := getSelectedSession()
- if sess == nil {
- updateStatus("[yellow]No session selected")
- return
- }
-
- // Exit TUI and attach to tmux
- app.Suspend(func() {
- // Read tmux session name from session
- tmuxPath := filepath.Join(sess.ID, "tmux")
- tmuxData, err := readFile(tmuxPath)
- if err != nil {
- logging.Logger().Error("failed to read tmux session", zap.String("session", sess.ID), zap.Error(err))
- fmt.Printf("Error reading tmux session: %v\n", err)
- fmt.Println("Press Enter to continue...")
- fmt.Scanln()
- return
- }
-
- tmuxSession := strings.TrimSpace(string(tmuxData))
- if tmuxSession == "" {
- logging.Logger().Warn("no tmux session found", zap.String("session", sess.ID))
- fmt.Println("No tmux session found for this agent")
- fmt.Println("Press Enter to continue...")
- fmt.Scanln()
- return
- }
-
- // Attach to tmux
- logging.Logger().Info("attaching to tmux session", zap.String("session", sess.ID), zap.String("tmux", tmuxSession))
- fmt.Printf("Attaching to tmux session: %s\n", tmuxSession)
- fmt.Printf("Press Ctrl-b d to detach\n\n")
-
- cmd := exec.Command("tmux", "attach-session", "-t", tmuxSession)
- cmd.Stdin = os.Stdin
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
-
- if err := cmd.Run(); err != nil {
- logging.Logger().Error("failed to attach to tmux", zap.String("tmux", tmuxSession), zap.Error(err))
- fmt.Printf("Error attaching to tmux: %v\n", err)
- fmt.Println("Press Enter to continue...")
- fmt.Scanln()
- }
- })
-}
-
-func showMessage(msg map[string]interface{}) {
- body := ""
- if b, ok := msg["body"].(string); ok {
- body = b
- }
- from := ""
- if f, ok := msg["from"].(string); ok {
- from = f
- }
- subject := ""
- if s, ok := msg["subject"].(string); ok {
- subject = s
- }
- msgType := ""
- if t, ok := msg["type"].(string); ok {
- msgType = t
- }
-
- text := fmt.Sprintf("[yellow]From:[-] %s\n[yellow]Subject:[-] %s\n[yellow]Type:[-] %s\n\n%s", from, subject, msgType, body)
-
- textView := tview.NewTextView().
- SetDynamicColors(true).
- SetText(text).
- SetScrollable(true)
-
- // Show approve/reject keybindings for actionable request types
- needsApproval := msgType == "APPROVAL_REQUEST" || msgType == "REVIEW_REQUEST"
- title := " Message | Q:close"
- if needsApproval {
- title = " Message | A:approve | X:reject | Q:close"
- }
-
- textView.SetBorder(true).SetTitle(title)
- textView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Rune() == 'q' || event.Key() == tcell.KeyEscape {
- pages.RemovePage("message")
- return nil
- }
- if event.Rune() == 'a' && needsApproval {
- if err := approveMessage(msg); err != nil {
- updateStatus(fmt.Sprintf("[red]Error approving message: %v", err))
- } else {
- updateStatus(fmt.Sprintf("[green]Approved message from %s", from))
- pages.RemovePage("message")
- if pages.HasPage("inbox") {
- pages.RemovePage("inbox")
- showInbox()
- }
- }
- return nil
- }
- if event.Rune() == 'x' && needsApproval {
- showRejectDialog(msg)
- return nil
- }
- return event
- })
-
- pages.AddPage("message", createModalDynamic(textView, 8, 16), true, true)
-}
-
-func showBeads() {
- showBeadsFiltered("")
-}
-
-func showBeadsFiltered(searchQuery string) {
- // Read ready beads
- beadsData, err := readFile(filepath.Join("beads", currentMount, "ready"))
- if err != nil {
- updateStatus(fmt.Sprintf("[red]Error reading beads: %v", err))
- return
- }
-
- var allBeads []map[string]interface{}
- if err := json.Unmarshal(beadsData, &allBeads); err != nil {
- updateStatus(fmt.Sprintf("[red]Error parsing beads: %v", err))
- return
- }
-
- // Filter beads if search query provided
- var beads []map[string]interface{}
- searchLower := strings.ToLower(searchQuery)
- for _, bead := range allBeads {
- if searchQuery == "" {
- beads = append(beads, bead)
- } else {
- title := ""
- if t, ok := bead["title"].(string); ok {
- title = strings.ToLower(t)
- }
- if strings.Contains(title, searchLower) {
- beads = append(beads, bead)
- }
- }
- }
-
- list := tview.NewList().ShowSecondaryText(true)
- list.SetSelectedBackgroundColor(tcell.ColorWhite)
- list.SetSelectedTextColor(tcell.ColorBlack)
-
- for i, bead := range beads {
- idx := i
- id := ""
- if bid, ok := bead["id"].(string); ok {
- id = bid
- }
- title := ""
- if t, ok := bead["title"].(string); ok {
- title = t
- }
- status := ""
- if s, ok := bead["status"].(string); ok {
- status = s
- }
-
- list.AddItem(
- fmt.Sprintf("(%s) %s", id, title),
- status,
- 0,
- func() {
- showBead(beads[idx])
- },
- )
- }
-
- // Get selected session info for display
- sess := getSelectedSession()
- sessionInfo := ""
- if sess != nil {
- sessionInfo = fmt.Sprintf("(%s) ", sess.ID[:8])
- }
-
- titleStr := fmt.Sprintf(" %sReady Beads (%d) | N:new | C:subtask | S:search | W:assign | R:refresh | Q:quit ", sessionInfo, len(beads))
- if searchQuery != "" {
- titleStr = fmt.Sprintf(" %sSearch: '%s' (%d) | N:new | C:subtask | S:search | W:assign | R:refresh | Q:quit ", sessionInfo, searchQuery, len(beads))
- }
-
- list.SetBorder(true).
- SetTitle(titleStr).
- SetTitleAlign(tview.AlignLeft).
- SetBorderColor(tcell.ColorBlue)
-
- list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Rune() == 'q' || event.Key() == tcell.KeyEscape {
- pages.RemovePage("beads")
- return nil
- }
- if event.Rune() == 'w' {
- // Get selected bead
- idx := list.GetCurrentItem()
- if idx >= 0 && idx < len(beads) {
- showAssignBeadConfirm(beads[idx])
- }
- return nil
- }
- if event.Rune() == 'n' {
- showNewBeadDialog()
- return nil
- }
- if event.Rune() == 'c' {
- // Get selected bead
- idx := list.GetCurrentItem()
- if idx >= 0 && idx < len(beads) {
- parentID := ""
- if bid, ok := beads[idx]["id"].(string); ok {
- parentID = bid
- }
- if parentID != "" {
- showNewSubtaskDialog(parentID)
- }
- }
- return nil
- }
- if event.Rune() == 's' {
- showBeadSearchDialog()
- return nil
- }
- if event.Rune() == 'r' {
- pages.RemovePage("beads")
- showBeads()
- return nil
- }
- return event
- })
-
- pages.AddPage("beads", createModalDynamic(list, 8, 16), true, true)
-}
-
-func showBeadSearchDialog() {
- input := tview.NewInputField().
- SetLabel("Search: ").
- SetFieldWidth(0).
- SetFieldTextColor(tcell.ColorBlack).
- SetFieldBackgroundColor(tcell.ColorWhite)
-
- input.SetDoneFunc(func(key tcell.Key) {
- if key == tcell.KeyEnter {
- query := input.GetText()
- pages.RemovePage("bead-search")
- pages.RemovePage("beads")
- showBeadsFiltered(query)
- } else {
- pages.RemovePage("bead-search")
- }
- })
-
- container := tview.NewFlex().
- SetDirection(tview.FlexRow).
- AddItem(input, 1, 0, true)
-
- container.SetBorder(true).
- SetTitle(" Search Beads by Title ").
- SetTitleAlign(tview.AlignLeft).
- SetBorderColor(tcell.ColorBlue)
-
- pages.AddPage("bead-search", createModal(container, 50, 5), true, true)
-}
-
-func showBead(bead map[string]interface{}) {
- id := ""
- if bid, ok := bead["id"].(string); ok {
- id = bid
- }
- title := ""
- if t, ok := bead["title"].(string); ok {
- title = t
- }
- description := ""
- if d, ok := bead["description"].(string); ok {
- description = d
- }
- status := ""
- if s, ok := bead["status"].(string); ok {
- status = s
- }
-
- requiresApproval := false
- requiresReview := false
- if labels, ok := bead["labels"].([]interface{}); ok {
- for _, l := range labels {
- if s, ok := l.(string); ok {
- if s == "requires_approval" {
- requiresApproval = true
- }
- if s == "requires_review" {
- requiresReview = true
- }
- }
- }
- }
- approvalStr := "no"
- if requiresApproval {
- approvalStr = "[green]YES[-]"
- }
- reviewStr := "no"
- if requiresReview {
- reviewStr = "[green]YES[-]"
- }
-
- text := fmt.Sprintf("[yellow]ID:[-] %s\n[yellow]Title:[-] %s\n[yellow]Status:[-] %s\n[yellow]Requires Approval:[-] %s\n[yellow]Requires Review:[-] %s\n\n%s", id, title, status, approvalStr, reviewStr, description)
-
- textView := tview.NewTextView().
- SetDynamicColors(true).
- SetText(text).
- SetScrollable(true)
-
- textView.SetBorder(true).SetTitle(" Bead Details | E:edit | N:new subtask | Q:quit ")
- textView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Rune() == 'q' || event.Key() == tcell.KeyEscape {
- pages.RemovePage("bead")
- return nil
- }
- if event.Rune() == 'n' {
- showNewSubtaskDialog(id)
- return nil
- }
- if event.Rune() == 'e' {
- showEditBeadDialog(bead)
- return nil
- }
- return event
- })
-
- pages.AddPage("bead", createModalDynamic(textView, 8, 16), true, true)
-}
-
-func showNewBeadDialog() {
- titleInput := tview.NewInputField().
- SetLabel("Title: ").
- SetFieldWidth(0).
- SetFieldTextColor(tcell.ColorBlack).
- SetFieldBackgroundColor(tcell.ColorWhite)
-
- descArea := tview.NewTextArea().
- SetPlaceholder("Description (optional)...")
-
- form := tview.NewForm().
- AddFormItem(titleInput).
- AddFormItem(descArea)
- form.SetFieldTextColor(tcell.ColorBlack)
- form.SetButtonTextColor(tcell.ColorBlack)
-
- form.AddButton("Create", func() {
- title := titleInput.GetText()
- desc := descArea.GetText()
-
- if title == "" {
- updateStatus("[red]Title cannot be empty")
- return
- }
-
- // Escape single quotes in title and description
- title = strings.ReplaceAll(title, "'", "\\'")
- desc = strings.ReplaceAll(desc, "'", "\\'")
-
- cmd := fmt.Sprintf("new '%s' '%s'", title, desc)
- if err := writeFile("beads/ctl", []byte(cmd)); err != nil {
- updateStatus(fmt.Sprintf("[red]Error creating bead: %v", err))
- } else {
- updateStatus(fmt.Sprintf("[green]Created bead: %s", title))
- pages.RemovePage("new-bead")
- // Refresh beads view if it's open
- if pages.HasPage("beads") {
- pages.RemovePage("beads")
- showBeads()
- }
- }
- })
-
- form.AddButton("Cancel", func() {
- pages.RemovePage("new-bead")
- })
-
- form.SetBorder(true).
- SetTitle(" Create New Bead ").
- SetTitleAlign(tview.AlignLeft).
- SetBorderColor(tcell.ColorBlue)
-
- pages.AddPage("new-bead", createModal(form, 80, 15), true, true)
-}
-
-func showNewSubtaskDialog(parentID string) {
- titleInput := tview.NewInputField().
- SetLabel("Title: ").
- SetFieldWidth(0).
- SetFieldTextColor(tcell.ColorBlack).
- SetFieldBackgroundColor(tcell.ColorWhite)
-
- descArea := tview.NewTextArea().
- SetPlaceholder("Description (optional)...")
-
- form := tview.NewForm().
- AddFormItem(titleInput).
- AddFormItem(descArea)
- form.SetFieldTextColor(tcell.ColorBlack)
- form.SetButtonTextColor(tcell.ColorBlack)
-
- form.AddButton("Create", func() {
- title := titleInput.GetText()
- desc := descArea.GetText()
-
- if title == "" {
- updateStatus("[red]Title cannot be empty")
- return
- }
-
- // Escape single quotes in title and description
- title = strings.ReplaceAll(title, "'", "\\'")
- desc = strings.ReplaceAll(desc, "'", "\\'")
-
- cmd := fmt.Sprintf("new '%s' '%s' %s", title, desc, parentID)
- if err := writeFile("beads/ctl", []byte(cmd)); err != nil {
- updateStatus(fmt.Sprintf("[red]Error creating subtask: %v", err))
- } else {
- updateStatus(fmt.Sprintf("[green]Created subtask under %s: %s", parentID, title))
- pages.RemovePage("new-subtask")
- pages.RemovePage("bead")
- }
- })
-
- form.AddButton("Cancel", func() {
- pages.RemovePage("new-subtask")
- })
-
- form.SetBorder(true).
- SetTitle(fmt.Sprintf(" Create Subtask (parent: %s) ", parentID)).
- SetTitleAlign(tview.AlignLeft).
- SetBorderColor(tcell.ColorBlue)
-
- pages.AddPage("new-subtask", createModal(form, 80, 15), true, true)
-}
-
-func showEditBeadDialog(bead map[string]interface{}) {
- id := ""
- if bid, ok := bead["id"].(string); ok {
- id = bid
- }
- title := ""
- if t, ok := bead["title"].(string); ok {
- title = t
- }
- description := ""
- if d, ok := bead["description"].(string); ok {
- description = d
- }
- status := ""
- if s, ok := bead["status"].(string); ok {
- status = s
- }
- priority := 0
- if p, ok := bead["priority"].(float64); ok {
- priority = int(p)
- }
- requiresApproval := false
- requiresReview := false
- if labels, ok := bead["labels"].([]interface{}); ok {
- for _, l := range labels {
- if s, ok := l.(string); ok {
- if s == "requires_approval" {
- requiresApproval = true
- }
- if s == "requires_review" {
- requiresReview = true
- }
- }
- }
- }
-
- titleInput := tview.NewInputField().
- SetLabel("Title: ").
- SetText(title).
- SetFieldWidth(0).
- SetFieldTextColor(tcell.ColorBlack).
- SetFieldBackgroundColor(tcell.ColorWhite)
-
- descArea := tview.NewTextArea().
- SetText(description, false)
-
- statusInput := tview.NewInputField().
- SetLabel("Status: ").
- SetText(status).
- SetFieldWidth(0).
- SetFieldTextColor(tcell.ColorBlack).
- SetFieldBackgroundColor(tcell.ColorWhite)
-
- priorityInput := tview.NewInputField().
- SetLabel("Priority: ").
- SetText(fmt.Sprintf("%d", priority)).
- SetFieldWidth(0).
- SetFieldTextColor(tcell.ColorBlack).
- SetFieldBackgroundColor(tcell.ColorWhite)
-
- approvalCheck := tview.NewCheckbox().
- SetLabel("Requires Approval: ").
- SetChecked(requiresApproval)
-
- reviewCheck := tview.NewCheckbox().
- SetLabel("Requires Review: ").
- SetChecked(requiresReview)
-
- form := tview.NewForm().
- AddFormItem(titleInput).
- AddFormItem(descArea).
- AddFormItem(statusInput).
- AddFormItem(priorityInput).
- AddFormItem(approvalCheck).
- AddFormItem(reviewCheck)
- form.SetFieldTextColor(tcell.ColorBlack)
- form.SetButtonTextColor(tcell.ColorBlack)
-
- form.AddButton("Save", func() {
- newTitle := titleInput.GetText()
- newDesc := descArea.GetText()
- newStatus := statusInput.GetText()
- newPriorityStr := priorityInput.GetText()
- newRequiresApproval := approvalCheck.IsChecked()
- newRequiresReview := reviewCheck.IsChecked()
-
- if newTitle == "" {
- updateStatus("[red]Title cannot be empty")
- return
- }
-
- // Update title if changed
- if newTitle != title {
- cmd := fmt.Sprintf("update %s title '%s'", id, strings.ReplaceAll(newTitle, "'", "\\'"))
- if err := writeFile("beads/ctl", []byte(cmd)); err != nil {
- updateStatus(fmt.Sprintf("[red]Error updating title: %v", err))
- return
- }
- }
-
- // Update description if changed
- if newDesc != description {
- cmd := fmt.Sprintf("update %s description '%s'", id, strings.ReplaceAll(newDesc, "'", "\\'"))
- if err := writeFile("beads/ctl", []byte(cmd)); err != nil {
- updateStatus(fmt.Sprintf("[red]Error updating description: %v", err))
- return
- }
- }
-
- // Update status if changed
- if newStatus != status {
- cmd := fmt.Sprintf("update %s status %s", id, newStatus)
- if err := writeFile("beads/ctl", []byte(cmd)); err != nil {
- updateStatus(fmt.Sprintf("[red]Error updating status: %v", err))
- return
- }
- }
-
- // Update priority if changed
- if newPriorityStr != fmt.Sprintf("%d", priority) {
- cmd := fmt.Sprintf("update %s priority %s", id, newPriorityStr)
- if err := writeFile("beads/ctl", []byte(cmd)); err != nil {
- updateStatus(fmt.Sprintf("[red]Error updating priority: %v", err))
- return
- }
- }
-
- // Toggle requires_approval label
- if newRequiresApproval != requiresApproval {
- if newRequiresApproval {
- writeFile("beads/ctl", []byte(fmt.Sprintf("label %s requires_approval", id)))
- } else {
- writeFile("beads/ctl", []byte(fmt.Sprintf("unlabel %s requires_approval", id)))
- }
- }
-
- // Toggle requires_review label
- if newRequiresReview != requiresReview {
- if newRequiresReview {
- writeFile("beads/ctl", []byte(fmt.Sprintf("label %s requires_review", id)))
- } else {
- writeFile("beads/ctl", []byte(fmt.Sprintf("unlabel %s requires_review", id)))
- }
- }
-
- updateStatus(fmt.Sprintf("[green]Updated bead %s", id))
- pages.RemovePage("edit-bead")
- pages.RemovePage("bead")
- // Refresh beads view if it's open
- if pages.HasPage("beads") {
- pages.RemovePage("beads")
- showBeads()
- }
- })
-
- form.AddButton("Cancel", func() {
- pages.RemovePage("edit-bead")
- })
-
- form.SetBorder(true).
- SetTitle(fmt.Sprintf(" Edit Bead: %s ", id)).
- SetTitleAlign(tview.AlignLeft).
- SetBorderColor(tcell.ColorBlue)
-
- pages.AddPage("edit-bead", createModal(form, 80, 20), true, true)
-}
-
-func showAssignBeadConfirm(bead map[string]interface{}) {
- // Get currently selected session
- sess := getSelectedSession()
- if sess == nil {
- updateStatus("[yellow]No session selected")
- return
- }
-
- beadID := ""
- if bid, ok := bead["id"].(string); ok {
- beadID = bid
- }
- beadTitle := ""
- if t, ok := bead["title"].(string); ok {
- beadTitle = t
- }
-
- if beadID == "" {
- updateStatus("[red]Invalid bead")
- return
- }
-
- message := fmt.Sprintf("Agent %s will work on %s (%s).\n\nContinue?", sess.ID[:8], beadID, beadTitle)
-
- textView := tview.NewTextView().
- SetText(message).
- SetTextAlign(tview.AlignCenter)
-
- form := tview.NewForm()
- form.SetButtonTextColor(tcell.ColorBlack)
- form.AddButton("Yes", func() {
- // Send prompt to work on the bead
- prompt := fmt.Sprintf("Work on bead %s, mount=%s", beadID, currentMount)
- if err := sendPrompt(sess.ID, prompt); err != nil {
- updateStatus(fmt.Sprintf("[red]Error sending prompt: %v", err))
- } else {
- updateStatus(fmt.Sprintf("[green]Assigned bead %s to %s", beadID, sess.ID[:8]))
- }
- pages.RemovePage("assign-confirm")
- })
-
- form.AddButton("No", func() {
- pages.RemovePage("assign-confirm")
- })
-
- layout := tview.NewFlex().
- SetDirection(tview.FlexRow).
- AddItem(textView, 0, 1, false).
- AddItem(form, 3, 0, true)
-
- layout.SetBorder(true).
- SetTitle(" Assign Bead ").
- SetTitleAlign(tview.AlignLeft).
- SetBorderColor(tcell.ColorBlue)
-
- pages.AddPage("assign-confirm", createModal(layout, 60, 10), true, true)
-}
-
-func showHelp() {
- helpText := `
-[yellow]AnviLLM TUI - Keyboard Shortcuts[-]
-
-[yellow]Session Management:[-]
- s Start new session (shows backend menu)
- T Stop selected session
- R Restart selected session
- K Kill selected session
- A Set session alias
- t Attach to session tmux
-
-[yellow]Interaction:[-]
- space Send prompt to session
- c Edit session context
- i View inbox
- a View archive
- b View/claim beads (tasks)
- r Refresh session list
-
-[yellow]Inbox:[-]
- a Approve selected message (sends APPROVAL/REVIEW_RESPONSE)
- x Reject selected message (prompts for reason)
- Enter View message details
- q/Esc Close inbox
-
-[yellow]Navigation:[-]
- ↑/↓ Select session (arrow keys)
- j/n Select next session (down)
- k/p Select previous session (up)
- C-n/C-p Select session (emacs-style)
-
-[yellow]Other:[-]
- d Daemon status
- ? Show this help
- q Quit (or ESC in dialogs)
-
-[yellow]Beads:[-]
- b View ready beads
- n Create new bead
- c Create subtask of SELECTED bead
- e Edit bead (in bead detail view)
- s Search beads by title (blank = show all)
- w Assign SELECTED bead to CURRENTLY SELECTED SESSION (sends prompt)
- r Refresh / clear search
-
-[yellow]9P Filesystem:[-]
-All operations read/write the 9P filesystem at $NAMESPACE/anvillm
-
-[yellow]Backends & Models:[-]
-Press 's' to start a new session and choose from:
- - claude (Claude Code CLI) — then select model tier:
- default use backend default (sonnet)
- haiku fast, cheap, simple tasks
- sonnet multi-file edits, moderate reasoning
- opus novel design, long-horizon planning
- - kiro-cli (Kiro CLI) — uses backend default model
-`
-
- textView := tview.NewTextView().
- SetDynamicColors(true).
- SetText(helpText).
- SetScrollable(true)
-
- textView.SetBorder(true).SetTitle(" Help ")
- textView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Rune() == 'q' || event.Key() == tcell.KeyEscape {
- pages.RemovePage("help")
- return nil
- }
- return event
- })
-
- pages.AddPage("help", createModal(textView, 70, 30), true, true)
-}
-
-func createModal(p tview.Primitive, width, height int) tview.Primitive {
- return tview.NewFlex().
- AddItem(nil, 0, 1, false).
- AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
- AddItem(nil, 0, 1, false).
- AddItem(p, height, 1, true).
- AddItem(nil, 0, 1, false), width, 1, true).
- AddItem(nil, 0, 1, false)
-}
-
-func createModalDynamic(p tview.Primitive, widthProportion, heightProportion int) tview.Primitive {
- return tview.NewFlex().
- AddItem(nil, 0, 1, false).
- AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
- AddItem(nil, 0, 1, false).
- AddItem(p, 0, heightProportion, true).
- AddItem(nil, 0, 1, false), 0, widthProportion, true).
- AddItem(nil, 0, 1, false)
-}
-
-func showMountManager() {
- // Read mtab
- mtabData, err := readFile("beads/mtab")
- if err != nil {
- updateStatus(fmt.Sprintf("[red]Error reading mtab: %v", err))
- return
- }
-
- lines := strings.Split(strings.TrimSpace(string(mtabData)), "\n")
- mounts := make(map[string]string) // mount -> cwd
- for _, line := range lines {
- if line == "" {
- continue
- }
- parts := strings.Split(line, "\t")
- if len(parts) == 2 {
- mounts[parts[0]] = parts[1]
- }
- }
-
- list := tview.NewList().ShowSecondaryText(true)
- list.SetSelectedBackgroundColor(tcell.ColorWhite)
- list.SetSelectedTextColor(tcell.ColorBlack)
-
- for mount, cwd := range mounts {
- m := mount
- list.AddItem(
- fmt.Sprintf("[%s] %s", m, cwd),
- "Press Enter to select, 'u' to unmount",
- 0,
- func() {
- currentMount = m
- updateStatus(fmt.Sprintf("Selected mount: %s", m))
- pages.RemovePage("mounts")
- },
- )
- }
-
- list.AddItem(
- "[+] Mount new project",
- "Press Enter to mount",
- 0,
- func() {
- showMountDialog()
- },
- )
-
- list.SetBorder(true).SetTitle(fmt.Sprintf(" Beads Mounts (current: %s) ", currentMount))
- list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Rune() == 'q' || event.Key() == tcell.KeyEscape {
- pages.RemovePage("mounts")
- return nil
- }
- if event.Rune() == 'u' {
- idx := list.GetCurrentItem()
- if idx < len(mounts) {
- // Get mount name from list
- mountNames := make([]string, 0, len(mounts))
- for m := range mounts {
- mountNames = append(mountNames, m)
- }
- if idx < len(mountNames) {
- mount := mountNames[idx]
- if err := writeFile("beads/ctl", []byte(fmt.Sprintf("umount %s", mount))); err != nil {
- updateStatus(fmt.Sprintf("[red]Error unmounting: %v", err))
- } else {
- updateStatus(fmt.Sprintf("Unmounted %s", mount))
- pages.RemovePage("mounts")
- }
- }
- }
- return nil
- }
- return event
- })
-
- pages.AddPage("mounts", createModal(list, 80, 20), true, true)
-}
-
-func showMountDialog() {
- form := tview.NewForm()
- form.AddInputField("Project path", "", 50, nil, nil)
- form.AddInputField("Mount name", "", 30, nil, nil)
- form.AddButton("Mount", func() {
- cwd := form.GetFormItem(0).(*tview.InputField).GetText()
- name := form.GetFormItem(1).(*tview.InputField).GetText()
- if cwd == "" || name == "" {
- updateStatus("[red]Both fields required")
- return
- }
- cmd := fmt.Sprintf("mount %s %s", cwd, name)
- if err := writeFile("beads/ctl", []byte(cmd)); err != nil {
- updateStatus(fmt.Sprintf("[red]Error mounting: %v", err))
- } else {
- currentMount = name
- updateStatus(fmt.Sprintf("Mounted %s at %s", name, cwd))
- pages.RemovePage("mount-dialog")
- pages.RemovePage("mounts")
- }
- })
- form.AddButton("Cancel", func() {
- pages.RemovePage("mount-dialog")
- })
-
- form.SetBorder(true).SetTitle(" Mount Project ")
- pages.AddPage("mount-dialog", createModal(form, 60, 10), true, true)
-}
diff --git a/cmd/anvilweb/main.go b/cmd/anvilweb/main.go
deleted file mode 100644
index e8ae4a6..0000000
--- a/cmd/anvilweb/main.go
+++ /dev/null
@@ -1,60 +0,0 @@
-// 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 (
- "anvillm/internal/logging"
- "embed"
- "flag"
- "fmt"
- "io/fs"
- "net/http"
- "net/http/httputil"
- "net/url"
- "os"
-
- "go.uber.org/zap"
-)
-
-//go:embed static/*
-var static embed.FS
-
-var (
- addr = flag.String("addr", ":8080", "HTTP server address")
- gwAddr = flag.String("gw", "http://localhost:8081", "anvilwebgw gateway URL for /api/* requests")
-)
-
-func main() {
- if err := logging.Init(); err != nil {
- fmt.Fprintf(os.Stderr, "Failed to initialize logging: %v\n", err)
- os.Exit(1)
- }
- defer logging.Logger().Sync()
-
- flag.Parse()
-
- gwURL, err := url.Parse(*gwAddr)
- if err != nil {
- logging.Logger().Fatal("invalid gateway URL", zap.String("url", *gwAddr), zap.Error(err))
- }
-
- staticFS, err := fs.Sub(static, "static")
- if err != nil {
- logging.Logger().Fatal("failed to load static files", zap.Error(err))
- }
-
- // Reverse proxy for /api/* to anvilwebgw
- proxy := httputil.NewSingleHostReverseProxy(gwURL)
-
- mux := http.NewServeMux()
- mux.Handle("/api/", proxy)
- mux.Handle("/", http.FileServer(http.FS(staticFS)))
-
- logging.Logger().Info("starting anvilweb", zap.String("addr", *addr), zap.String("gateway", *gwAddr))
- if err := http.ListenAndServe(*addr, mux); err != nil {
- logging.Logger().Fatal("server error", zap.Error(err))
- }
-}
diff --git a/cmd/anvilweb/mkfile b/cmd/anvilweb/mkfile
deleted file mode 100644
index 00a4cc5..0000000
--- a/cmd/anvilweb/mkfile
+++ /dev/null
@@ -1,2 +0,0 @@
-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
deleted file mode 100644
index 58212aa..0000000
--- a/cmd/anvilweb/static/index.html
+++ /dev/null
@@ -1,1596 +0,0 @@
-
-
-
-
-
- AnviLLM Web
-
-
-
-
-
-
-
-
- Checking...
-
-
-
-
-
-
-
-
-
-
-
-
Sessions
-
-
-
-
-
-
-
-
-
-
-
-
Inbox
-
-
-
-
-
-
-
-
-
-
-
-
Archive
-
-
-
-
-
-
-
-
-
-
-
Tasks
-
-
-
-
-
-
-
- Filter:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/cmd/anvilwebgw/main.go b/cmd/anvilwebgw/main.go
deleted file mode 100644
index fa526bf..0000000
--- a/cmd/anvilwebgw/main.go
+++ /dev/null
@@ -1,1246 +0,0 @@
-// 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 (
- "anvillm/internal/logging"
- "encoding/json"
- "flag"
- "fmt"
- "io"
- "net/http"
- "os"
- "os/exec"
- "os/signal"
- "path/filepath"
- "strconv"
- "strings"
- "syscall"
- "time"
-
- "9fans.net/go/plan9"
- "9fans.net/go/plan9/client"
- "go.uber.org/zap"
-)
-
-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) {
- if err := logging.Init(); err != nil {
- fmt.Fprintf(os.Stderr, "Failed to initialize logging: %v\n", err)
- os.Exit(1)
- }
- defer logging.Logger().Sync()
-
- pidFile := getPidFilePath()
-
- // Check if already running before daemonizing
- if existingPid := readPidFile(); existingPid != 0 {
- if isProcessRunning(existingPid) {
- logging.Logger().Fatal("anvilwebgw already running", zap.Int("pid", existingPid))
- }
- // Stale PID file
- logging.Logger().Warn("removing stale PID file", zap.Int("pid", existingPid))
- os.Remove(pidFile)
- }
-
- // Daemonize if requested
- if daemonize {
- if os.Getenv("ANVILWEBGW_DAEMON") != "1" {
- logging.Logger().Info("daemonizing anvilwebgw")
- cmd, err := os.Executable()
- if err != nil {
- logging.Logger().Fatal("failed to get executable path", zap.Error(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 {
- fmt.Fprintf(os.Stderr, "Failed to daemonize: %v\n", err)
- os.Exit(1)
- }
- 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()
- logging.Logger().Info("starting anvilwebgw", zap.Int("pid", pid), zap.Bool("daemonized", daemonize))
- 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) {
- logging.Logger().Fatal("anvilwebgw already running", zap.Int("pid", existingPid))
- }
- logging.Logger().Warn("removing stale PID file on startup", zap.Int("pid", existingPid))
- os.Remove(pidFile)
- f, err = os.OpenFile(pidFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
- if err != nil {
- fmt.Fprintf(os.Stderr, "Failed to create PID file: %v\n", err)
- os.Exit(1)
- }
- } else {
- fmt.Fprintf(os.Stderr, "Failed to create PID file: %v\n", err)
- os.Exit(1)
- }
- }
- if _, err := fmt.Fprintf(f, "%d\n", pid); err != nil {
- f.Close()
- os.Remove(pidFile)
- logging.Logger().Fatal("failed to write PID file", zap.Error(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/mtab", handleBeadsMtab)
- mux.HandleFunc("/api/beads/mount", handleBeadsMount)
- 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
- logging.Logger().Info("received signal, shutting down", zap.String("signal", sig.String()))
- server.Close()
- }()
-
- logging.Logger().Info("anvilwebgw started successfully", zap.String("addr", *addr), zap.String("namespace", *namespace))
-
- if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- logging.Logger().Fatal("server error", zap.Error(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)
- }
-
- logging.Logger().Info("stopping anvilwebgw", zap.Int("pid", pid))
- fmt.Printf("Stopping 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)
- }
-
- logging.Logger().Info("anvilwebgw status check", zap.Int("pid", pid), zap.Bool("running", true))
- 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, "anvillm"))
-}
-
-// --- 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"`
- Role string `json:"role,omitempty"`
-}
-
-// --- 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
- }
-
- sessions := []Session{}
- for _, line := range strings.Split(string(data), "\n") {
- if line == "" {
- continue
- }
- fields := strings.Fields(line)
- if len(fields) < 6 {
- continue
- }
- // list format: id backend state alias role cwd
- alias := fields[3]
- if alias == "-" {
- alias = ""
- }
- role := fields[4]
- if role == "-" {
- role = ""
- }
- sess := Session{
- ID: fields[0],
- Backend: fields[1],
- State: fields[2],
- Alias: alias,
- Role: role,
- CWD: strings.Join(fields[5:], " "),
- }
-
- 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"`
- Model string `json:"model"`
- }
-
- 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()
-
- cmd := fmt.Sprintf("new %s %s", req.Backend, req.CWD)
- if req.Model != "" {
- cmd += fmt.Sprintf(" model=%s", req.Model)
- }
- 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":
- 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.Role = readField("role")
- 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"`
- Timestamp int64 `json:"timestamp"`
-}
-
-type InboxMessage struct {
- Message
- Filename string `json:"filename"`
-}
-
-// --- 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
- }
-
- messages = append(messages, InboxMessage{
- Message: msg,
- Filename: d.Name,
- })
- }
- }
- 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
- }
-
- mount := r.URL.Query().Get("mount")
- if mount == "" {
- mount = "default"
- }
-
- fs, err := connect()
- if err != nil {
- http.Error(w, err.Error(), http.StatusServiceUnavailable)
- return
- }
- defer fs.Close()
-
- fid, err := fs.Open(filepath.Join("beads", mount, "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 handleBeadsMtab(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/mtab", 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
- }
-
- // Parse mtab format: mount\tcwd per line
- lines := strings.Split(strings.TrimSpace(string(data)), "\n")
- mounts := make([]map[string]string, 0)
- for _, line := range lines {
- if line == "" {
- continue
- }
- parts := strings.Split(line, "\t")
- if len(parts) == 2 {
- mounts = append(mounts, map[string]string{
- "mount": parts[0],
- "cwd": parts[1],
- })
- }
- }
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(mounts)
-}
-
-func handleBeadsMount(w http.ResponseWriter, r *http.Request) {
- if r.Method == "POST" {
- // Mount
- var req struct {
- Cwd string `json:"cwd"`
- Name string `json:"name"`
- }
- 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()
-
- cmd := fmt.Sprintf("mount %s %s", req.Cwd, req.Name)
- if _, err := fid.Write([]byte(cmd)); 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"})
- } else if r.Method == "DELETE" {
- // Unmount
- var req struct {
- Name string `json:"name"`
- }
- 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()
-
- cmd := fmt.Sprintf("umount %s", req.Name)
- if _, err := fid.Write([]byte(cmd)); 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"})
- } else {
- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
- }
-}
-
-func handleBeadsReady(w http.ResponseWriter, r *http.Request) {
- if r.Method != "GET" {
- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
- return
- }
-
- mount := r.URL.Query().Get("mount")
- if mount == "" {
- mount = "default"
- }
-
- fs, err := connect()
- if err != nil {
- http.Error(w, err.Error(), http.StatusServiceUnavailable)
- return
- }
- defer fs.Close()
-
- fid, err := fs.Open(filepath.Join("beads", mount, "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
- }
-
- mount := r.URL.Query().Get("mount")
- if mount == "" {
- mount = "default"
- }
-
- 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(filepath.Join("beads", mount, "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
- }
-
- mount := r.URL.Query().Get("mount")
- if mount == "" {
- mount = "default"
- }
-
- 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", mount, 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/docs/diagrams/architecture.mmd b/docs/diagrams/architecture.mmd
index b168bf3..d6394e0 100644
--- a/docs/diagrams/architecture.mmd
+++ b/docs/diagrams/architecture.mmd
@@ -1,15 +1,15 @@
graph TB
- subgraph Clients
+ subgraph Clients["Frontends (any 9P client)"]
Assist["Assist
(Acme)"]
- TUI["TUI"]
- Emacs["Emacs"]
- Web["Web"]
+ Custom["Custom
(your own)"]
end
+ WebApp["Web App"]
+
subgraph Core["Core Services"]
anvilsrv["anvilsrv
(9P daemon)"]
anvilmcp["anvilmcp
(MCP server)"]
- anvilwebgw["anvilwebgw
(API proxy)"]
+ anvilwebgw["anvilwebgw
(HTTP↔9P bridge)"]
end
subgraph Backends
@@ -20,9 +20,8 @@ graph TB
end
Assist -->|9P| anvilsrv
- TUI -->|9P| anvilsrv
- Emacs -->|9P| anvilsrv
- Web -->|HTTP| anvilwebgw
+ Custom -->|9P| anvilsrv
+ WebApp -->|HTTP| anvilwebgw
anvilwebgw -->|9P| anvilsrv
anvilsrv --> Claude
diff --git a/docs/diagrams/architecture.svg b/docs/diagrams/architecture.svg
index aa5eadd..77bdcd9 100644
--- a/docs/diagrams/architecture.svg
+++ b/docs/diagrams/architecture.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/docs/diagrams/client-interactions.mmd b/docs/diagrams/client-interactions.mmd
index ca2561f..6cf1147 100644
--- a/docs/diagrams/client-interactions.mmd
+++ b/docs/diagrams/client-interactions.mmd
@@ -1,9 +1,8 @@
graph TB
- Assist["Assist"]
- TUI["TUI"]
+ Frontend["Frontend
(any 9P client)"]
Script["Script"]
FS["anvillm/
(9P filesystem)"]
- Assist -->|9p read
anvillm/list| FS
- TUI -->|9p write
anvillm/ctl| FS
+ Frontend -->|9p read
anvillm/list| FS
+ Frontend -->|9p write
anvillm/ctl| FS
Script -->|9p read
anvillm/events| FS
diff --git a/docs/diagrams/client-interactions.svg b/docs/diagrams/client-interactions.svg
index bdcf4c9..c55fff2 100644
--- a/docs/diagrams/client-interactions.svg
+++ b/docs/diagrams/client-interactions.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/elisp/anvillm.el b/elisp/anvillm.el
deleted file mode 100644
index 2829d41..0000000
--- a/elisp/anvillm.el
+++ /dev/null
@@ -1,1640 +0,0 @@
-;;; anvillm.el --- Emacs interface for AnviLLM -*- lexical-binding: t; -*-
-
-;; Copyright (C) 2026
-
-;; Author: Levi Neely
-;; Keywords: tools, processes
-;; Version: 1.0.0
-;; Package-Requires: ((emacs "25.1"))
-
-;;; Commentary:
-
-;; Emacs interface for AnviLLM - Acme-native LLM chat backend manager.
-;; Provides a session manager that interacts with anvilsrv via 9P filesystem.
-;;
-;; Usage:
-;; M-x anvillm
-;;
-;; Keybindings in session list:
-;; s - Start new session (select backend and capability level)
-;; SPC - Compose prompt in buffer
-;; M-SPC - Send prompt to selected session (minibuffer)
-;; p/n - Move up/down
-;; t - Stop selected session
-;; R - Restart selected session
-;; K - Kill selected session
-;; a - Attach to tmux session
-;; A - Set alias for selected session
-;; r - Refresh session list
-;; g - Refresh session list (standard Emacs binding)
-;; d - Daemon status
-;; q - Quit
-;; ? - Help
-
-;;; Code:
-
-(require 'tabulated-list)
-(require 'json)
-
-(defgroup anvillm nil
- "Emacs interface for AnviLLM."
- :group 'tools
- :prefix "anvillm-")
-
-(defcustom anvillm-refresh-interval 2
- "Interval in seconds for auto-refreshing the session list."
- :type 'integer
- :group 'anvillm)
-
-(defcustom anvillm-9p-command "9p"
- "Path to the 9p command from plan9port."
- :type 'string
- :group 'anvillm)
-
-(defvar anvillm-refresh-timer nil
- "Timer for auto-refreshing the session list.")
-
-(defvar anvillm-agent-path "agent"
- "9P filesystem path for AnviLLM agent.")
-
-(defvar anvillm-current-mount "default"
- "Current beads mount point.")
-
-;;; 9P Filesystem Interface
-
-(defun anvillm--9p-read (path)
- "Read from 9P filesystem PATH using the 9p command."
- (with-temp-buffer
- (let ((exit-code (call-process anvillm-9p-command nil t nil "read" path)))
- (if (zerop exit-code)
- (buffer-string)
- (error "Failed to read %s: %s" path (buffer-string))))))
-
-(defun anvillm--9p-ls (path)
- "List directory at 9P filesystem PATH using the 9p command."
- (with-temp-buffer
- (let ((exit-code (call-process anvillm-9p-command nil t nil "ls" path)))
- (if (zerop exit-code)
- (buffer-string)
- (error "Failed to ls %s: %s" path (buffer-string))))))
-
-(defun anvillm--9p-read-nonblocking (path callback)
- "Read from 9P filesystem PATH asynchronously, calling CALLBACK with data.
-The process is killed after the first read to prevent blocking on streaming files."
- (let* ((buffer (generate-new-buffer " *9p-read*"))
- (proc (start-process "9p-read" buffer anvillm-9p-command "read" path)))
- (set-process-filter
- proc
- (lambda (process output)
- (when (buffer-live-p (process-buffer process))
- (with-current-buffer (process-buffer process)
- (goto-char (point-max))
- (insert output)))
- ;; Kill process after receiving data to prevent blocking
- (delete-process process)
- (when (buffer-live-p buffer)
- (with-current-buffer buffer
- (funcall callback (buffer-string)))
- (kill-buffer buffer))))
- (set-process-sentinel
- proc
- (lambda (process event)
- (when (buffer-live-p (process-buffer process))
- (let ((buffer (process-buffer process)))
- (with-current-buffer buffer
- (funcall callback (buffer-string)))
- (kill-buffer buffer)))))))
-
-(defun anvillm--9p-write (path data)
- "Write DATA to 9P filesystem PATH using the 9p command."
- (with-temp-buffer
- (insert data)
- (let ((exit-code (call-process-region (point-min) (point-max)
- anvillm-9p-command nil t nil
- "write" path)))
- (unless (zerop exit-code)
- (error "Failed to write %s: %s" path (buffer-string))))))
-
-;;; Session Management
-
-(defun anvillm--abbreviate-path (path)
- "Abbreviate PATH, replacing home directory with ~."
- (abbreviate-file-name path))
-
-(defun anvillm--expand-path (path)
- "Expand PATH, replacing ~ with home directory."
- (expand-file-name path))
-
-(defun anvillm--capability-to-model (capability)
- "Resolve CAPABILITY level (low/standard/high) to model name.
-Applies to claude and kiro-cli backends: low→haiku, standard→sonnet, high→opus."
- (cond
- ((string= capability "low") "haiku")
- ((string= capability "high") "opus")
- (t "sonnet")))
-
-(defun anvillm--parse-session-line (line)
- "Parse a session LINE from the 'list' file.
-Format: id backend state alias model cwd (whitespace-separated; often tabs)."
- (when (string-match
- "^\\([^[:space:]]+\\)\\s-+\\([^[:space:]]+\\)\\s-+\\([^[:space:]]+\\)\\s-+\\([^[:space:]]+\\)\\s-+\\([^[:space:]]+\\)\\s-+\\(.+\\)$"
- line)
- (let ((id (match-string 1 line))
- (backend (match-string 2 line))
- (state (match-string 3 line))
- (alias (match-string 4 line))
- (model (match-string 5 line))
- (cwd (match-string 6 line)))
- (when (string= alias "-") (setq alias ""))
- (when (string= model "-") (setq model ""))
- (list id (vector
- (substring id 0 (min 8 (length id)))
- alias
- backend
- (propertize state 'face (anvillm--state-face state))
- (or model "-")
- (anvillm--abbreviate-path cwd))))))
-
-(defun anvillm--state-face (state)
- "Return face for session STATE."
- (cond
- ((string= state "running") 'success)
- ((string= state "idle") 'font-lock-function-name-face)
- ((string= state "stopped") 'warning)
- ((or (string= state "error") (string= state "exited")) 'error)
- (t 'default)))
-
-(defun anvillm--list-sessions ()
- "Get list of sessions from the 9P filesystem."
- (condition-case err
- (let ((list-data (anvillm--9p-read (concat anvillm-agent-path "/list"))))
- (delq nil
- (mapcar #'anvillm--parse-session-line
- (split-string list-data "\n" t))))
- (error
- (message "Failed to list sessions: %s" (error-message-string err))
- nil)))
-
-(defun anvillm--refresh-sessions ()
- "Refresh the session list in the current buffer."
- (when (eq major-mode 'anvillm-mode)
- (let ((sessions (anvillm--list-sessions)))
- (setq tabulated-list-entries
- (mapcar (lambda (session)
- (list (car session) (cadr session)))
- sessions))
- (tabulated-list-print t))))
-
-;;; Interactive Commands
-
-(defun anvillm-refresh ()
- "Refresh the session list."
- (interactive)
- (anvillm--refresh-sessions))
-
-(defun anvillm-start-session ()
- "Start a new session after selecting backend and capability level."
- (interactive)
- (let ((backend (completing-read "Select backend: " '("claude" "kiro-cli") nil t)))
- (when backend
- (let* ((directory (read-directory-name "Working directory: " default-directory))
- (expanded-dir (anvillm--expand-path directory))
- (capability (completing-read "Capability level: "
- '("standard" "low" "high")
- nil t nil nil "standard"))
- (model (anvillm--capability-to-model capability)))
- (condition-case err
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/ctl")
- (format "new %s %s model=%s" backend expanded-dir model))
- (message "Created %s session (%s) in %s" backend model (anvillm--abbreviate-path expanded-dir))
- (anvillm--refresh-sessions))
- (error
- (message "Failed to create session: %s" (error-message-string err))))))))
-
-(defun anvillm--get-selected-session ()
- "Get the ID of the currently selected session."
- (when-let ((entry (tabulated-list-get-id)))
- entry))
-
-(defun anvillm-stop-session ()
- "Stop the selected session."
- (interactive)
- (if-let ((session-id (anvillm--get-selected-session)))
- (condition-case err
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/" session-id "/ctl") "stop")
- (message "Stopped session %s" (substring session-id 0 (min 8 (length session-id))))
- (anvillm--refresh-sessions))
- (error
- (message "Failed to stop session: %s" (error-message-string err))))
- (message "No session selected")))
-
-(defun anvillm-restart-session ()
- "Restart the selected session."
- (interactive)
- (if-let ((session-id (anvillm--get-selected-session)))
- (condition-case err
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/" session-id "/ctl") "restart")
- (message "Restarted session %s" (substring session-id 0 (min 8 (length session-id))))
- (anvillm--refresh-sessions))
- (error
- (message "Failed to restart session: %s" (error-message-string err))))
- (message "No session selected")))
-
-(defun anvillm-kill-session ()
- "Kill the selected session."
- (interactive)
- (if-let ((session-id (anvillm--get-selected-session)))
- (when (yes-or-no-p (format "Kill session %s? " (substring session-id 0 (min 8 (length session-id)))))
- (condition-case err
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/" session-id "/ctl") "kill")
- (message "Killed session %s" (substring session-id 0 (min 8 (length session-id))))
- (anvillm--refresh-sessions))
- (error
- (message "Failed to kill session: %s" (error-message-string err)))))
- (message "No session selected")))
-
-(defun anvillm-set-alias ()
- "Set alias for the selected session."
- (interactive)
- (if-let ((session-id (anvillm--get-selected-session)))
- (let ((current-alias (condition-case nil
- (string-trim (anvillm--9p-read (concat anvillm-agent-path "/" session-id "/alias")))
- (error "")))
- (new-alias (read-string "Alias: ")))
- (condition-case err
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/" session-id "/alias") new-alias)
- (message "Set alias to '%s'" new-alias)
- (anvillm--refresh-sessions))
- (error
- (message "Failed to set alias: %s" (error-message-string err)))))
- (message "No session selected")))
-
-(defun anvillm-assign-bead ()
- "Assign a bead to the selected agent to work on."
- (interactive)
- (if-let ((session-id (anvillm--get-selected-session)))
- (let* ((beads (condition-case nil
- (let* ((json-object-type 'plist)
- (json-array-type 'list)
- (beads-data (anvillm--9p-read (concat anvillm-agent-path "/beads/" anvillm-current-mount "/list")))
- (beads (json-read-from-string beads-data)))
- (mapcar (lambda (b) (plist-get b :id)) beads))
- (error nil)))
- (bead-id (completing-read "Bead ID: " beads nil nil)))
- (when (> (length bead-id) 0)
- (condition-case err
- (let ((msg (json-encode `((to . ,session-id)
- (type . "PROMPT_REQUEST")
- (subject . "User prompt")
- (body . ,(format "Work on bead %s" bead-id))))))
- (anvillm--9p-write (concat anvillm-agent-path "/user/mail") msg)
- (message "Assigned bead %s to %s" bead-id (substring session-id 0 (min 8 (length session-id)))))
- (error
- (message "Failed to assign bead: %s" (error-message-string err))))))
- (message "No session selected")))
-
-(defun anvillm-daemon-status ()
- "Show daemon status."
- (interactive)
- (message "Checking daemon status...")
- (with-temp-buffer
- (let ((exit-code (call-process "anvilsrv" nil t nil "status")))
- (message "%s" (buffer-string)))))
-
-(defun anvillm-help ()
- "Show help for AnviLLM mode."
- (interactive)
- (with-help-window "*AnviLLM Help*"
- (princ "AnviLLM - Emacs Interface
-
-Keybindings:
-s - Start new session (select backend and capability level)
-SPC - Compose prompt in buffer (C-c C-c to send, C-c C-k to abort)
-M-SPC - Send prompt (minibuffer)
-l - View session log (press 'r' to refresh, 'q' to close)
-c - Edit session context (C-c C-c to save, C-c C-k to abort)
-i - Open inbox
-C - Open archive (completed messages)
-T - Open tasks/beads manager
-t - Stop selected session
-R - Restart selected session
-K - Kill selected session
-A - Set alias for selected session
-a - Attach to tmux session in terminal
-r, g - Refresh session list
-d - Daemon status
-q - Quit
-? - This help
-
-Navigation:
-n, C-n - Next line
-p, C-p - Previous line
-RET - (reserved for future use)
-
-Prompt Composition:
-The prompt buffer supports multi-line input. Lines starting with ;;
-are treated as comments and stripped before sending.
-
-Bead ID Detection:
-If you enter just a bead ID (e.g., bd-5xz), it will automatically
-be wrapped with instructions to load the beads skill and work on it.
-
-Context:
-Session context is a text prefix prepended to all prompts sent to
-that session. Use it to provide persistent instructions or information.
-
-9P Filesystem:
-All operations read/write the 9P filesystem at $NAMESPACE/agent
-
-Backends:
-- claude (Claude Code CLI)
-- kiro-cli (Kiro CLI)
-
-")))
-
-
-;;; Inbox Management
-
-(defvar-local anvillm--message-data nil
- "Message data for the current message view buffer.")
-
-(defvar anvillm-message-view-mode-map
- (let ((map (make-sparse-keymap)))
- (define-key map (kbd "r") #'anvillm-message-reply)
- (define-key map (kbd "q") #'quit-window)
- map)
- "Keymap for AnviLLM message view mode.")
-
-(define-derived-mode anvillm-message-view-mode special-mode "AnviLLM-Message"
- "Major mode for viewing AnviLLM messages.
-
-\\{anvillm-message-view-mode-map}")
-
-(defun anvillm-message-reply ()
- "Reply to the current message."
- (interactive)
- (unless anvillm--message-data
- (error "No message data available"))
- (let* ((from (plist-get anvillm--message-data :from))
- (subject (plist-get anvillm--message-data :subject))
- (reply-subject (if (string-prefix-p "Re: " subject)
- subject
- (concat "Re: " subject)))
- (buffer-name (format "*Reply to %s*" (substring from 0 (min 8 (length from)))))
- (buffer (get-buffer-create buffer-name)))
- (with-current-buffer buffer
- (anvillm-prompt-mode)
- (erase-buffer)
- (setq anvillm--prompt-session-id from)
- (insert (format ";; Reply to: %s\n" from))
- (insert (format ";; Subject: %s\n\n" reply-subject))
- (setq-local anvillm--reply-subject reply-subject))
- (pop-to-buffer buffer)
- (goto-char (point-max))))
-
-(defvar anvillm-inbox-mode-map
- (let ((map (make-sparse-keymap)))
- (set-keymap-parent map tabulated-list-mode-map)
- (define-key map (kbd "RET") #'anvillm-inbox-view)
- (define-key map (kbd "v") #'anvillm-inbox-view)
- (define-key map (kbd "r") #'anvillm-inbox-reply)
- (define-key map (kbd "a") #'anvillm-inbox-approve)
- (define-key map (kbd "R") #'anvillm-inbox-reject)
- (define-key map (kbd "d") #'anvillm-inbox-archive)
- (define-key map (kbd "g") #'anvillm-inbox-refresh)
- (define-key map (kbd "q") #'quit-window)
- (define-key map (kbd "?") #'anvillm-inbox-help)
- map)
- "Keymap for AnviLLM inbox mode.")
-
-(defun anvillm--list-inbox-messages ()
- "Get list of inbox messages from the 9P filesystem."
- (condition-case err
- (let* ((inbox-path (concat anvillm-agent-path "/user/inbox"))
- (files-str (anvillm--9p-ls inbox-path))
- (files (split-string files-str "\n" t))
- messages)
- (dolist (file files)
- (when (string-suffix-p ".json" file)
- (let* ((msg-path (concat inbox-path "/" file))
- (json-object-type 'plist)
- (json-array-type 'list)
- (msg-json (anvillm--9p-read msg-path))
- (msg (json-read-from-string msg-json))
- (id (plist-get msg :id))
- (from (plist-get msg :from))
- (type (plist-get msg :type))
- (subject (plist-get msg :subject))
- (timestamp (plist-get msg :timestamp)))
- (push (list id (vector
- (substring id 0 (min 8 (length id)))
- (substring from 0 (min 8 (length from)))
- type
- subject
- (format-time-string "%Y-%m-%d %H:%M"
- (seconds-to-time timestamp))))
- messages))))
- (nreverse messages))
- (error
- (message "Failed to list inbox: %s" (error-message-string err))
- nil)))
-
-(defun anvillm--refresh-inbox ()
- "Refresh the inbox list in the current buffer."
- (when (eq major-mode 'anvillm-inbox-mode)
- (let ((messages (anvillm--list-inbox-messages)))
- (setq tabulated-list-entries messages)
- (tabulated-list-print t))))
-
-(define-derived-mode anvillm-inbox-mode tabulated-list-mode "AnviLLM-Inbox"
- "Major mode for managing AnviLLM inbox.
-
-\\{anvillm-inbox-mode-map}"
- (setq tabulated-list-format [("ID" 10 t)
- ("From" 10 t)
- ("Type" 20 t)
- ("Subject" 30 t)
- ("Time" 16 t)])
- (setq tabulated-list-padding 2)
- (setq tabulated-list-sort-key (cons "Time" t))
- (tabulated-list-init-header))
-
-(defun anvillm-inbox ()
- "Open the AnviLLM inbox."
- (interactive)
- (let ((buffer (get-buffer-create "*AnviLLM Inbox*")))
- (with-current-buffer buffer
- (anvillm-inbox-mode)
- (anvillm--refresh-inbox))
- (switch-to-buffer buffer)))
-
-(defun anvillm-inbox-refresh ()
- "Refresh the inbox list."
- (interactive)
- (anvillm--refresh-inbox))
-
-(defun anvillm--get-selected-message ()
- "Get the ID of the currently selected message."
- (tabulated-list-get-id))
-
-(defun anvillm-inbox-view ()
- "View the selected message."
- (interactive)
- (if-let ((msg-id (anvillm--get-selected-message)))
- (condition-case err
- (let* ((json-object-type 'plist)
- (json-array-type 'list)
- (msg-path (concat anvillm-agent-path "/user/inbox/" msg-id ".json"))
- (msg-json (anvillm--9p-read msg-path))
- (msg (json-read-from-string msg-json))
- (buffer (get-buffer-create (format "*Message: %s*"
- (substring msg-id 0 (min 8 (length msg-id)))))))
- (with-current-buffer buffer
- (let ((inhibit-read-only t))
- (erase-buffer)
- (insert (format "From: %s\n" (plist-get msg :from)))
- (insert (format "To: %s\n" (plist-get msg :to)))
- (insert (format "Type: %s\n" (plist-get msg :type)))
- (insert (format "Subject: %s\n" (plist-get msg :subject)))
- (insert (format "Time: %s\n"
- (format-time-string "%Y-%m-%d %H:%M:%S"
- (seconds-to-time (plist-get msg :timestamp)))))
- (insert "\n")
- (insert (plist-get msg :body)))
- (anvillm-message-view-mode)
- (setq-local anvillm--message-data msg))
- (pop-to-buffer buffer))
- (error
- (message "Failed to view message: %s" (error-message-string err))))
- (message "No message selected")))
-
-(defun anvillm--infer-reply-type (msg-type)
- "Infer the appropriate reply type from MSG-TYPE.
-Maps APPROVAL_REQUEST to APPROVAL_RESPONSE, REVIEW_REQUEST to
-REVIEW_RESPONSE, and defaults to PROMPT_RESPONSE for all others."
- (cond
- ((string= msg-type "APPROVAL_REQUEST") "APPROVAL_RESPONSE")
- ((string= msg-type "REVIEW_REQUEST") "REVIEW_RESPONSE")
- (t "PROMPT_RESPONSE")))
-
-(defun anvillm-inbox-reply ()
- "Reply to the selected message.
-Automatically infers the correct reply type from the message type."
- (interactive)
- (if-let ((msg-id (anvillm--get-selected-message)))
- (condition-case err
- (let* ((json-object-type 'plist)
- (json-array-type 'list)
- (msg-path (concat anvillm-agent-path "/user/inbox/" msg-id ".json"))
- (msg-json (anvillm--9p-read msg-path))
- (msg (json-read-from-string msg-json))
- (from (plist-get msg :from))
- (msg-type (plist-get msg :type))
- (subject (plist-get msg :subject))
- (reply-type (anvillm--infer-reply-type msg-type))
- (reply-body (read-string "Reply: ")))
- (when (> (length reply-body) 0)
- (let ((reply-msg (json-encode `((to . ,from)
- (type . ,reply-type)
- (subject . ,(concat "Re: " subject))
- (body . ,reply-body)))))
- (anvillm--9p-write (concat anvillm-agent-path "/user/mail") reply-msg)
- (message "Sent reply to %s" from))))
- (error
- (message "Failed to reply: %s" (error-message-string err))))
- (message "No message selected")))
-
-(defun anvillm-inbox-approve ()
- "Approve the selected message.
-Sends an APPROVAL_RESPONSE with body \"approved\" to the sender,
-then automatically archives the message."
- (interactive)
- (if-let ((msg-id (anvillm--get-selected-message)))
- (condition-case err
- (let* ((json-object-type 'plist)
- (json-array-type 'list)
- (msg-path (concat anvillm-agent-path "/user/inbox/" msg-id ".json"))
- (msg-json (anvillm--9p-read msg-path))
- (msg (json-read-from-string msg-json))
- (from (plist-get msg :from))
- (subject (plist-get msg :subject)))
- (let ((reply-msg (json-encode `((to . ,from)
- (type . "APPROVAL_RESPONSE")
- (subject . ,(concat "Re: " subject))
- (body . "approved")))))
- (anvillm--9p-write (concat anvillm-agent-path "/user/mail") reply-msg)
- (anvillm--9p-write (concat anvillm-agent-path "/user/ctl")
- (format "complete %s" msg-id))
- (message "Approved and archived message from %s" from)
- (anvillm--refresh-inbox)))
- (error
- (message "Failed to approve: %s" (error-message-string err))))
- (message "No message selected")))
-
-(defun anvillm-inbox-reject ()
- "Reject the selected message with a reason.
-Prompts for a rejection reason, sends a response to the sender,
-then automatically archives the message."
- (interactive)
- (if-let ((msg-id (anvillm--get-selected-message)))
- (condition-case err
- (let* ((json-object-type 'plist)
- (json-array-type 'list)
- (msg-path (concat anvillm-agent-path "/user/inbox/" msg-id ".json"))
- (msg-json (anvillm--9p-read msg-path))
- (msg (json-read-from-string msg-json))
- (from (plist-get msg :from))
- (msg-type (plist-get msg :type))
- (subject (plist-get msg :subject))
- (reason (read-string "Rejection reason: ")))
- (when (> (length reason) 0)
- (let* ((reply-type (anvillm--infer-reply-type msg-type))
- (reply-msg (json-encode `((to . ,from)
- (type . ,reply-type)
- (subject . ,(concat "Re: " subject))
- (body . ,(format "rejected: %s" reason))))))
- (anvillm--9p-write (concat anvillm-agent-path "/user/mail") reply-msg)
- (anvillm--9p-write (concat anvillm-agent-path "/user/ctl")
- (format "complete %s" msg-id))
- (message "Rejected and archived message from %s" from)
- (anvillm--refresh-inbox))))
- (error
- (message "Failed to reject: %s" (error-message-string err))))
- (message "No message selected")))
-
-(defun anvillm-inbox-archive ()
- "Archive (complete) the selected message."
- (interactive)
- (if-let ((msg-id (anvillm--get-selected-message)))
- (condition-case err
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/user/ctl")
- (format "complete %s" msg-id))
- (message "Archived message %s" (substring msg-id 0 (min 8 (length msg-id))))
- (anvillm--refresh-inbox))
- (error
- (message "Failed to archive message: %s" (error-message-string err))))
- (message "No message selected")))
-
-(defun anvillm-inbox-help ()
- "Show help for AnviLLM inbox mode."
- (interactive)
- (with-help-window "*AnviLLM Inbox Help*"
- (princ "AnviLLM Inbox
-
-Keybindings:
-RET, v - View selected message
-r - Reply to selected message (type inferred from message)
-a - Approve selected message (sends APPROVAL_RESPONSE)
-R - Reject selected message with reason (sends APPROVAL_RESPONSE)
-d - Archive (complete) selected message
-g - Refresh inbox
-q - Quit
-? - This help
-
-Navigation:
-n, C-n - Next line
-p, C-p - Previous line
-
-Reply Type Inference:
-APPROVAL_REQUEST -> APPROVAL_RESPONSE
-REVIEW_REQUEST -> REVIEW_RESPONSE
-(all others) -> PROMPT_RESPONSE
-
-")))
-
-;;; Archive Management
-
-(defvar anvillm-archive-mode-map
- (let ((map (make-sparse-keymap)))
- (set-keymap-parent map tabulated-list-mode-map)
- (define-key map (kbd "RET") #'anvillm-archive-view)
- (define-key map (kbd "v") #'anvillm-archive-view)
- (define-key map (kbd "g") #'anvillm-archive-refresh)
- (define-key map (kbd "q") #'quit-window)
- (define-key map (kbd "?") #'anvillm-archive-help)
- map)
- "Keymap for AnviLLM archive mode.")
-
-(defun anvillm--list-archive-messages ()
- "Get list of archived (completed) messages from the 9P filesystem."
- (condition-case err
- (let* ((archive-path (concat anvillm-agent-path "/user/completed"))
- (files-str (anvillm--9p-ls archive-path))
- (files (split-string files-str "\n" t))
- messages)
- (dolist (file files)
- (when (string-suffix-p ".json" file)
- (let* ((msg-path (concat archive-path "/" file))
- (json-object-type 'plist)
- (json-array-type 'list)
- (msg-json (anvillm--9p-read msg-path))
- (msg (json-read-from-string msg-json))
- (id (plist-get msg :id))
- (from (plist-get msg :from))
- (type (plist-get msg :type))
- (subject (plist-get msg :subject))
- (timestamp (plist-get msg :timestamp)))
- (push (list id (vector
- (substring id 0 (min 8 (length id)))
- (substring from 0 (min 8 (length from)))
- type
- subject
- (format-time-string "%Y-%m-%d %H:%M"
- (seconds-to-time timestamp))))
- messages))))
- (nreverse messages))
- (error
- (message "Failed to list archive: %s" (error-message-string err))
- nil)))
-
-(defun anvillm--refresh-archive ()
- "Refresh the archive list in the current buffer."
- (when (eq major-mode 'anvillm-archive-mode)
- (let ((messages (anvillm--list-archive-messages)))
- (setq tabulated-list-entries messages)
- (tabulated-list-print t))))
-
-(define-derived-mode anvillm-archive-mode tabulated-list-mode "AnviLLM-Archive"
- "Major mode for viewing AnviLLM archived messages.
-
-\\{anvillm-archive-mode-map}"
- (setq tabulated-list-format [("ID" 10 t)
- ("From" 10 t)
- ("Type" 20 t)
- ("Subject" 30 t)
- ("Time" 16 t)])
- (setq tabulated-list-padding 2)
- (setq tabulated-list-sort-key (cons "Time" t))
- (tabulated-list-init-header))
-
-(defun anvillm-archive ()
- "Open the AnviLLM archive."
- (interactive)
- (let ((buffer (get-buffer-create "*AnviLLM Archive*")))
- (with-current-buffer buffer
- (anvillm-archive-mode)
- (anvillm--refresh-archive))
- (switch-to-buffer buffer)))
-
-(defun anvillm-archive-refresh ()
- "Refresh the archive list."
- (interactive)
- (anvillm--refresh-archive))
-
-(defun anvillm-archive-view ()
- "View the selected archived message."
- (interactive)
- (if-let ((msg-id (anvillm--get-selected-message)))
- (condition-case err
- (let* ((json-object-type 'plist)
- (json-array-type 'list)
- (msg-path (concat anvillm-agent-path "/user/completed/" msg-id ".json"))
- (msg-json (anvillm--9p-read msg-path))
- (msg (json-read-from-string msg-json))
- (buffer (get-buffer-create (format "*Archived Message: %s*"
- (substring msg-id 0 (min 8 (length msg-id)))))))
- (with-current-buffer buffer
- (let ((inhibit-read-only t))
- (erase-buffer)
- (insert (format "From: %s\n" (plist-get msg :from)))
- (insert (format "To: %s\n" (plist-get msg :to)))
- (insert (format "Type: %s\n" (plist-get msg :type)))
- (insert (format "Subject: %s\n" (plist-get msg :subject)))
- (insert (format "Time: %s\n"
- (format-time-string "%Y-%m-%d %H:%M:%S"
- (seconds-to-time (plist-get msg :timestamp)))))
- (insert "\n")
- (insert (plist-get msg :body)))
- (anvillm-message-view-mode)
- (setq-local anvillm--message-data msg))
- (pop-to-buffer buffer))
- (error
- (message "Failed to view message: %s" (error-message-string err))))
- (message "No message selected")))
-
-(defun anvillm-archive-help ()
- "Show help for AnviLLM archive mode."
- (interactive)
- (with-help-window "*AnviLLM Archive Help*"
- (princ "AnviLLM Archive
-
-Keybindings:
-RET, v - View selected message
-g - Refresh archive
-q - Quit
-? - This help
-
-Navigation:
-n, C-n - Next line
-p, C-p - Previous line
-
-")))
-
-;;; Context Management
-
-(defvar-local anvillm--context-session-id nil
- "Session ID for the current context buffer.")
-
-(defvar anvillm-context-mode-map
- (let ((map (make-sparse-keymap)))
- (define-key map (kbd "C-c C-c") #'anvillm-context-save)
- (define-key map (kbd "C-c C-k") #'anvillm-context-abort)
- map)
- "Keymap for AnviLLM context editing mode.")
-
-(define-derived-mode anvillm-context-mode text-mode "AnviLLM-Context"
- "Major mode for editing session context.
-
-\\{anvillm-context-mode-map}"
- (setq header-line-format
- (substitute-command-keys
- "Edit session context. Save: \\[anvillm-context-save] | Abort: \\[anvillm-context-abort]")))
-
-(defun anvillm-edit-context ()
- "Edit context for the selected session."
- (interactive)
- (if-let ((session-id (anvillm--get-selected-session)))
- (condition-case err
- (let* ((current-context (string-trim
- (anvillm--9p-read
- (concat anvillm-agent-path "/" session-id "/context"))))
- (session-info (anvillm--get-session-info session-id))
- (display-name (or (plist-get session-info :alias)
- (substring session-id 0 (min 8 (length session-id)))))
- (buffer-name (format "*AnviLLM Context: %s*" display-name))
- (buffer (get-buffer-create buffer-name)))
- (with-current-buffer buffer
- (anvillm-context-mode)
- (erase-buffer)
- (setq anvillm--context-session-id session-id)
- (when (> (length current-context) 0)
- (insert current-context))
- (goto-char (point-min)))
- (pop-to-buffer buffer))
- (error
- (message "Failed to load context: %s" (error-message-string err))))
- (message "No session selected")))
-
-(defun anvillm-context-save ()
- "Save the edited context to the session."
- (interactive)
- (unless anvillm--context-session-id
- (error "No session ID associated with this buffer"))
- (let ((context (buffer-string)))
- (condition-case err
- (progn
- (anvillm--9p-write
- (concat anvillm-agent-path "/" anvillm--context-session-id "/context")
- context)
- (message "Context saved for %s"
- (substring anvillm--context-session-id 0
- (min 8 (length anvillm--context-session-id))))
- (quit-window t))
- (error
- (message "Failed to save context: %s" (error-message-string err))))))
-
-(defun anvillm-context-abort ()
- "Abort context editing and close the buffer."
- (interactive)
- (when (yes-or-no-p "Discard context changes? ")
- (quit-window t)))
-
-;;; Tasks/Beads Management
-
-(defvar anvillm-tasks-mode-map
- (let ((map (make-sparse-keymap)))
- (set-keymap-parent map tabulated-list-mode-map)
- (define-key map (kbd "SPC") #'anvillm-tasks-new)
- (define-key map (kbd "s") #'anvillm-tasks-new-subtask)
- (define-key map (kbd "w") #'anvillm-tasks-assign-to-agent)
- (define-key map (kbd "n") #'next-line)
- (define-key map (kbd "p") #'previous-line)
- (define-key map (kbd "c") #'anvillm-tasks-claim)
- (define-key map (kbd "C") #'anvillm-tasks-complete)
- (define-key map (kbd "f") #'anvillm-tasks-fail)
- (define-key map (kbd "RET") #'anvillm-tasks-view)
- (define-key map (kbd "v") #'anvillm-tasks-view)
- (define-key map (kbd "d") #'anvillm-tasks-add-dependency)
- (define-key map (kbd "D") #'anvillm-tasks-remove-dependency)
- (define-key map (kbd "l") #'anvillm-tasks-add-label)
- (define-key map (kbd "L") #'anvillm-tasks-remove-label)
- (define-key map (kbd "A") #'anvillm-bead-toggle-approval)
- (define-key map (kbd "R") #'anvillm-bead-toggle-review)
- (define-key map (kbd "m") #'anvillm-tasks-comment)
- (define-key map (kbd "r") #'anvillm-tasks-refresh)
- (define-key map (kbd "g") #'anvillm-tasks-refresh)
- (define-key map (kbd "q") #'quit-window)
- (define-key map (kbd "?") #'anvillm-tasks-help)
- map)
- "Keymap for AnviLLM tasks mode.")
-
-(defun anvillm--parse-bead (json-str)
- "Parse a bead from JSON-STR."
- (let* ((json-object-type 'plist)
- (json-array-type 'list)
- (bead (json-read-from-string json-str)))
- bead))
-
-(defun anvillm--list-beads ()
- "Get list of beads from the 9P filesystem."
- (condition-case err
- (let* ((json-object-type 'plist)
- (json-array-type 'list)
- (beads-data (anvillm--9p-read (concat anvillm-agent-path "/beads/ready")))
- (beads (json-read-from-string beads-data)))
- (mapcar (lambda (bead)
- (let ((id (plist-get bead :id))
- (title (plist-get bead :title))
- (status (plist-get bead :status))
- (priority (or (plist-get bead :priority) 0))
- (assignee (or (plist-get bead :assignee) "")))
- (list id (vector
- id
- (propertize status 'face (anvillm--bead-status-face status))
- (number-to-string priority)
- assignee
- title))))
- beads))
- (error
- (message "Failed to list beads: %s" (error-message-string err))
- nil)))
-
-(defun anvillm--bead-status-face (status)
- "Return face for bead STATUS."
- (cond
- ((string= status "open") 'warning)
- ((string= status "in_progress") 'font-lock-function-name-face)
- ((string= status "closed") 'success)
- (t 'default)))
-
-(defun anvillm--refresh-tasks ()
- "Refresh the tasks list in the current buffer."
- (when (eq major-mode 'anvillm-tasks-mode)
- (let ((beads (anvillm--list-beads)))
- (setq tabulated-list-entries beads)
- (tabulated-list-print t))))
-
-(define-derived-mode anvillm-tasks-mode tabulated-list-mode "AnviLLM-Tasks"
- "Major mode for managing AnviLLM tasks/beads.
-
-\\{anvillm-tasks-mode-map}"
- (setq tabulated-list-format [("ID" 15 t)
- ("Status" 12 t)
- ("Pri" 4 t)
- ("Assignee" 12 t)
- ("Title" 0 t)])
- (setq tabulated-list-padding 2)
- (setq tabulated-list-sort-key (cons "ID" nil))
- (tabulated-list-init-header))
-
-(defun anvillm-tasks ()
- "Open the AnviLLM tasks manager."
- (interactive)
- (let ((buffer (get-buffer-create "*AnviLLM Tasks*")))
- (with-current-buffer buffer
- (anvillm-tasks-mode)
- (anvillm--refresh-tasks))
- (switch-to-buffer buffer)))
-
-(defun anvillm-tasks-refresh ()
- "Refresh the tasks list."
- (interactive)
- (anvillm--refresh-tasks))
-
-(defun anvillm-tasks-new ()
- "Create a new bead."
- (interactive)
- (let ((title (read-string "Title: "))
- (description (read-string "Description (optional): ")))
- (when (> (length title) 0)
- (condition-case err
- (let ((cmd (if (> (length description) 0)
- (format "new '%s' '%s'" title description)
- (format "new '%s'" title))))
- (anvillm--9p-write (concat anvillm-agent-path "/beads/ctl") cmd)
- (message "Created bead: %s" title)
- (anvillm--refresh-tasks))
- (error
- (message "Failed to create bead: %s" (error-message-string err)))))))
-
-(defun anvillm-tasks-new-subtask ()
- "Create a new subtask for the selected bead."
- (interactive)
- (if-let ((parent-id (anvillm--get-selected-bead)))
- (let ((title (read-string "Subtask title: "))
- (description (read-string "Description (optional): ")))
- (when (> (length title) 0)
- (condition-case err
- (let ((cmd (if (> (length description) 0)
- (format "new '%s' '%s' %s" title description parent-id)
- (format "new '%s' '' %s" title parent-id))))
- (anvillm--9p-write (concat anvillm-agent-path "/beads/ctl") cmd)
- (message "Created subtask of %s: %s" parent-id title)
- (anvillm--refresh-tasks))
- (error
- (message "Failed to create subtask: %s" (error-message-string err))))))
- (message "No bead selected")))
-
-(defun anvillm--get-selected-bead ()
- "Get the ID of the currently selected bead."
- (tabulated-list-get-id))
-
-(defun anvillm-tasks-claim ()
- "Claim the selected bead."
- (interactive)
- (if-let ((bead-id (anvillm--get-selected-bead)))
- (condition-case err
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/beads/ctl")
- (format "claim %s" bead-id))
- (message "Claimed bead %s" bead-id)
- (anvillm--refresh-tasks))
- (error
- (message "Failed to claim bead: %s" (error-message-string err))))
- (message "No bead selected")))
-
-(defun anvillm-tasks-complete ()
- "Complete the selected bead."
- (interactive)
- (if-let ((bead-id (anvillm--get-selected-bead)))
- (condition-case err
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/beads/ctl")
- (format "complete %s" bead-id))
- (message "Completed bead %s" bead-id)
- (anvillm--refresh-tasks))
- (error
- (message "Failed to complete bead: %s" (error-message-string err))))
- (message "No bead selected")))
-
-(defun anvillm-tasks-fail ()
- "Fail the selected bead."
- (interactive)
- (if-let ((bead-id (anvillm--get-selected-bead)))
- (let ((reason (read-string "Reason: ")))
- (condition-case err
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/beads/ctl")
- (format "fail %s '%s'" bead-id reason))
- (message "Failed bead %s" bead-id)
- (anvillm--refresh-tasks))
- (error
- (message "Failed to fail bead: %s" (error-message-string err)))))
- (message "No bead selected")))
-
-(defun anvillm-tasks-view ()
- "View details of the selected bead."
- (interactive)
- (if-let ((bead-id (anvillm--get-selected-bead)))
- (condition-case err
- (let* ((json-object-type 'plist)
- (json-array-type 'list)
- (bead-json (anvillm--9p-read
- (concat anvillm-agent-path "/beads/" bead-id "/json")))
- (bead (json-read-from-string bead-json))
- (buffer (get-buffer-create (format "*Bead: %s*" bead-id))))
- (with-current-buffer buffer
- (erase-buffer)
- (insert (format "ID: %s\n" (plist-get bead :id)))
- (insert (format "Title: %s\n" (plist-get bead :title)))
- (insert (format "Status: %s\n" (plist-get bead :status)))
- (insert (format "Priority: %s\n" (or (plist-get bead :priority) "N/A")))
- (insert (format "Assignee: %s\n" (or (plist-get bead :assignee) "N/A")))
- (insert (format "Type: %s\n" (or (plist-get bead :issue_type) "N/A")))
- (insert (format "Created: %s\n" (plist-get bead :created_at)))
- (insert (format "Updated: %s\n" (plist-get bead :updated_at)))
- (when-let ((desc (plist-get bead :description)))
- (insert (format "\nDescription:\n%s\n" desc)))
- (when-let ((blockers (plist-get bead :blockers)))
- (insert (format "\nBlockers (%d):\n" (length blockers)))
- (dolist (blocker blockers)
- (insert (format " - %s\n" blocker))))
- (let* ((labels (plist-get bead :labels))
- (label-list (if (vectorp labels) (append labels nil) nil))
- (has-approval (member "requires_approval" label-list))
- (has-review (member "requires_review" label-list)))
- (insert (format "\nRequires Approval: %s\n" (if has-approval "YES" "no")))
- (insert (format "Requires Review: %s\n" (if has-review "YES" "no"))))
- (special-mode))
- (pop-to-buffer buffer))
- (error
- (message "Failed to view bead: %s" (error-message-string err))))
- (message "No bead selected")))
-
-(defun anvillm-tasks-add-dependency ()
- "Add a dependency to the selected bead."
- (interactive)
- (if-let ((child-id (anvillm--get-selected-bead)))
- (let ((parent-id (read-string "Parent bead ID: ")))
- (when (> (length parent-id) 0)
- (condition-case err
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/beads/ctl")
- (format "dep %s %s" child-id parent-id))
- (message "Added dependency: %s blocks %s" parent-id child-id)
- (anvillm--refresh-tasks))
- (error
- (message "Failed to add dependency: %s" (error-message-string err))))))
- (message "No bead selected")))
-
-(defun anvillm-tasks-remove-dependency ()
- "Remove a dependency from the selected bead."
- (interactive)
- (if-let ((child-id (anvillm--get-selected-bead)))
- (let ((parent-id (read-string "Parent bead ID to remove: ")))
- (when (> (length parent-id) 0)
- (condition-case err
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/beads/ctl")
- (format "undep %s %s" child-id parent-id))
- (message "Removed dependency: %s no longer blocks %s" parent-id child-id)
- (anvillm--refresh-tasks))
- (error
- (message "Failed to remove dependency: %s" (error-message-string err))))))
- (message "No bead selected")))
-
-(defun anvillm-tasks-add-label ()
- "Add a label to the selected bead."
- (interactive)
- (if-let ((bead-id (anvillm--get-selected-bead)))
- (let ((label (read-string "Label: ")))
- (when (> (length label) 0)
- (condition-case err
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/beads/ctl")
- (format "label %s '%s'" bead-id label))
- (message "Added label '%s' to %s" label bead-id)
- (anvillm--refresh-tasks))
- (error
- (message "Failed to add label: %s" (error-message-string err))))))
- (message "No bead selected")))
-
-(defun anvillm-tasks-remove-label ()
- "Remove a label from the selected bead."
- (interactive)
- (if-let ((bead-id (anvillm--get-selected-bead)))
- (let ((label (read-string "Label to remove: ")))
- (when (> (length label) 0)
- (condition-case err
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/beads/ctl")
- (format "unlabel %s '%s'" bead-id label))
- (message "Removed label '%s' from %s" label bead-id)
- (anvillm--refresh-tasks))
- (error
- (message "Failed to remove label: %s" (error-message-string err))))))
- (message "No bead selected")))
-
-(defun anvillm-bead-toggle-approval ()
- "Toggle the requires_approval flag on the selected bead."
- (interactive)
- (if-let ((bead-id (anvillm--get-selected-bead)))
- (condition-case err
- (let* ((json-str (anvillm--9p-read
- (concat anvillm-agent-path "/beads/" bead-id "/json")))
- (bead (json-read-from-string json-str))
- (labels (plist-get bead :labels))
- (label-list (if (vectorp labels) (append labels nil) nil))
- (has-flag (member "requires_approval" label-list)))
- (if has-flag
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/beads/ctl")
- (format "unlabel %s requires_approval" bead-id))
- (message "Cleared requires_approval on %s" bead-id))
- (anvillm--9p-write (concat anvillm-agent-path "/beads/ctl")
- (format "label %s requires_approval" bead-id))
- (message "Set requires_approval on %s" bead-id))
- (anvillm--refresh-tasks))
- (error
- (message "Failed to toggle requires_approval: %s" (error-message-string err))))
- (message "No bead selected")))
-
-(defun anvillm-bead-toggle-review ()
- "Toggle the requires_review flag on the selected bead."
- (interactive)
- (if-let ((bead-id (anvillm--get-selected-bead)))
- (condition-case err
- (let* ((json-str (anvillm--9p-read
- (concat anvillm-agent-path "/beads/" bead-id "/json")))
- (bead (json-read-from-string json-str))
- (labels (plist-get bead :labels))
- (label-list (if (vectorp labels) (append labels nil) nil))
- (has-flag (member "requires_review" label-list)))
- (if has-flag
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/beads/ctl")
- (format "unlabel %s requires_review" bead-id))
- (message "Cleared requires_review on %s" bead-id))
- (anvillm--9p-write (concat anvillm-agent-path "/beads/ctl")
- (format "label %s requires_review" bead-id))
- (message "Set requires_review on %s" bead-id))
- (anvillm--refresh-tasks))
- (error
- (message "Failed to toggle requires_review: %s" (error-message-string err))))
- (message "No bead selected")))
-
-(defun anvillm-tasks-assign-to-agent ()
- "Assign the selected bead to an agent to work on."
- (interactive)
- (if-let ((bead-id (anvillm--get-selected-bead)))
- (let* ((sessions (anvillm--list-sessions))
- (agent-ids (mapcar #'car sessions))
- (agent-id (completing-read "Agent ID: " agent-ids nil nil)))
- (when (> (length agent-id) 0)
- (condition-case err
- (let ((msg (json-encode `((to . ,agent-id)
- (type . "PROMPT_REQUEST")
- (subject . "User prompt")
- (body . ,(format "Work on bead %s" bead-id))))))
- (anvillm--9p-write (concat anvillm-agent-path "/user/mail") msg)
- (message "Assigned bead %s to %s" bead-id (substring agent-id 0 (min 8 (length agent-id)))))
- (error
- (message "Failed to assign bead: %s" (error-message-string err))))))
- (message "No bead selected")))
-
-
-(defun anvillm-tasks-comment ()
- "Add a comment to the selected bead."
- (interactive)
- (if-let ((bead-id (anvillm--get-selected-bead)))
- (let ((comment (read-string "Comment: ")))
- (when (> (length comment) 0)
- (condition-case err
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/beads/ctl")
- (format "comment %s '%s'" bead-id comment))
- (message "Added comment to %s" bead-id))
- (error
- (message "Failed to add comment: %s" (error-message-string err))))))
- (message "No bead selected")))
-
-(defun anvillm-tasks-help ()
- "Show help for AnviLLM tasks mode."
- (interactive)
- (with-help-window "*AnviLLM Tasks Help*"
- (princ "AnviLLM Tasks - Beads Management
-
-Keybindings:
-SPC - Create new bead
-s - Create new subtask for selected bead
-c - Claim selected bead
-C - Complete selected bead
-f - Fail selected bead (with reason)
-v - View bead details
-d - Add dependency (parent blocks child)
-D - Remove dependency
-l - Add label
-L - Remove label
-m - Add comment
-r, g - Refresh tasks list
-q - Quit
-? - This help
-
-Navigation:
-n, C-n - Next line
-p, C-p - Previous line
-
-Bead Status:
-open - Not yet started
-in_progress - Currently being worked on
-closed - Completed
-
-")))
-
-;;; Major Mode
-
-(defvar anvillm-mode-map
- (let ((map (make-sparse-keymap)))
- (set-keymap-parent map tabulated-list-mode-map)
- (define-key map (kbd "s") #'anvillm-start-session)
- (define-key map (kbd "T") #'anvillm-stop-session)
- (define-key map (kbd "R") #'anvillm-restart-session)
- (define-key map (kbd "K") #'anvillm-kill-session)
- (define-key map (kbd "A") #'anvillm-set-alias)
- (define-key map (kbd "a") #'anvillm-attach-session)
- (define-key map (kbd "w") #'anvillm-assign-bead)
- (define-key map (kbd "SPC") #'anvillm-compose-prompt)
- (define-key map (kbd "M-SPC") #'anvillm-send-prompt)
- (define-key map (kbd "p") #'previous-line)
- (define-key map (kbd "n") #'next-line)
- (define-key map (kbd "l") #'anvillm-view-log)
- (define-key map (kbd "c") #'anvillm-edit-context)
- (define-key map (kbd "i") #'anvillm-inbox)
- (define-key map (kbd "C") #'anvillm-archive)
- (define-key map (kbd "t") #'anvillm-tasks)
- (define-key map (kbd "r") #'anvillm-refresh)
- (define-key map (kbd "g") #'anvillm-refresh)
- (define-key map (kbd "d") #'anvillm-daemon-status)
- (define-key map (kbd "q") #'quit-window)
- (define-key map (kbd "?") #'anvillm-help)
- map)
- "Keymap for AnviLLM mode.")
-
-(define-derived-mode anvillm-mode tabulated-list-mode "AnviLLM"
- "Major mode for managing AnviLLM sessions.
-
-\\{anvillm-mode-map}"
- (setq tabulated-list-format [("ID" 10 t)
- ("Alias" 15 t)
- ("Backend" 12 t)
- ("State" 10 t)
- ("Model" 10 t)
- ("Cwd" 0 t)])
- (setq tabulated-list-padding 2)
- (setq tabulated-list-sort-key (cons "ID" nil))
- (tabulated-list-init-header)
-
- ;; Set up auto-refresh timer
- (when anvillm-refresh-timer
- (cancel-timer anvillm-refresh-timer))
- (setq anvillm-refresh-timer
- (run-at-time anvillm-refresh-interval anvillm-refresh-interval
- (lambda ()
- (when (and (buffer-live-p (get-buffer "*AnviLLM*"))
- (eq (buffer-local-value 'major-mode (get-buffer "*AnviLLM*")) 'anvillm-mode))
- (with-current-buffer "*AnviLLM*"
- (anvillm--refresh-sessions))))))
-
- ;; Clean up timer when buffer is killed
- (add-hook 'kill-buffer-hook
- (lambda ()
- (when anvillm-refresh-timer
- (cancel-timer anvillm-refresh-timer)
- (setq anvillm-refresh-timer nil)))
- nil t))
-
-;;;###autoload
-(defun anvillm ()
- "Open the AnviLLM session manager."
- (interactive)
- (let ((buffer (get-buffer-create "*AnviLLM*")))
- (with-current-buffer buffer
- (anvillm-mode)
- (anvillm--refresh-sessions))
- (switch-to-buffer buffer)))
-
-(defcustom anvillm-terminal-command
- (or (getenv "ANVILLM_TERMINAL") "foot")
- "Terminal emulator command for attaching to tmux sessions."
- :type 'string
- :group 'anvillm)
-
-(defun anvillm-attach-session ()
- "Attach to the tmux session for the selected session."
- (interactive)
- (if-let ((session-id (anvillm--get-selected-session)))
- (condition-case err
- (let ((tmux-target (string-trim (anvillm--9p-read (concat anvillm-agent-path "/" session-id "/tmux")))))
- (if (string-empty-p tmux-target)
- (message "Session does not support attach")
- (start-process
- (format "tmux-attach-%s" session-id)
- nil
- anvillm-terminal-command
- "-e" "tmux" "attach" "-t" tmux-target)
- (message "Attached to tmux session %s" tmux-target)))
- (error
- (message "Failed to attach to session: %s" (error-message-string err))))
- (message "No session selected")))
-
-(defvar-local anvillm--log-session-id nil
- "Session ID for the current log buffer.")
-
-(defvar-local anvillm--log-process nil
- "Process streaming log data for the current buffer.")
-
-(defvar anvillm-log-mode-map
- (let ((map (make-sparse-keymap)))
- (define-key map (kbd "r") #'anvillm-log-refresh)
- (define-key map (kbd "g") #'anvillm-log-refresh)
- (define-key map (kbd "q") #'anvillm-log-quit)
- map)
- "Keymap for AnviLLM log mode.")
-
-(defun anvillm-log-quit ()
- "Quit the log window and kill the streaming process."
- (interactive)
- ;; Kill the streaming process
- (when (and anvillm--log-process
- (process-live-p anvillm--log-process))
- (delete-process anvillm--log-process)
- (setq anvillm--log-process nil))
- ;; Quit the window
- (quit-window t))
-
-(define-derived-mode anvillm-log-mode special-mode "AnviLLM-Log"
- "Major mode for viewing AnviLLM session logs.
-
-\\{anvillm-log-mode-map}"
- (setq buffer-read-only t)
- (setq header-line-format
- (substitute-command-keys
- "AnviLLM Log (streaming). Refresh: \\[anvillm-log-refresh] | Quit: \\[quit-window]"))
- ;; Kill the streaming process when buffer is killed
- (add-hook 'kill-buffer-hook
- (lambda ()
- (when (and anvillm--log-process
- (process-live-p anvillm--log-process))
- (delete-process anvillm--log-process)))
- nil t))
-
-(defun anvillm-view-log ()
- "View the centralized audit log."
- (interactive)
- (let* ((buffer-name "*AnviLLM Audit Log*")
- (buffer (get-buffer-create buffer-name)))
- (with-current-buffer buffer
- (anvillm-log-mode)
- (setq anvillm--log-session-id nil) ; Not session-specific anymore
- (anvillm--refresh-log-buffer))
- (pop-to-buffer buffer)))
-
-(defun anvillm--refresh-log-buffer ()
- "Refresh the log content in the current buffer by starting a streaming read."
-
- ;; Kill existing process if any
- (when (and anvillm--log-process
- (process-live-p anvillm--log-process))
- (delete-process anvillm--log-process))
-
- (let ((inhibit-read-only t)
- (log-buffer (current-buffer)))
- (erase-buffer)
- (insert "Loading audit log...\n")
-
- ;; Start streaming process for centralized audit log
- (let* ((path (concat anvillm-agent-path "/audit"))
- (proc (start-process "9p-log-stream" log-buffer anvillm-9p-command "read" path)))
-
- (setq anvillm--log-process proc)
-
- (set-process-filter
- proc
- (lambda (process output)
- (when (buffer-live-p log-buffer)
- (with-current-buffer log-buffer
- (let ((inhibit-read-only t)
- (at-end (= (point) (point-max))))
- ;; Clear "Loading audit log..." on first output
- (when (save-excursion
- (goto-char (point-min))
- (looking-at "Loading audit log..."))
- (erase-buffer))
- ;; Insert new output
- (goto-char (point-max))
- (insert output)
- ;; Auto-scroll if we were at the end
- (when at-end
- (goto-char (point-max))))))))
-
- (set-process-sentinel
- proc
- (lambda (process event)
- (when (buffer-live-p log-buffer)
- (with-current-buffer log-buffer
- (let ((inhibit-read-only t))
- (when (and (= (point-min) (point-max))
- (not (string-match-p "^run" event)))
- (insert "No audit log entries yet.\n"))))))))))
-
-(defun anvillm-log-refresh ()
- "Refresh the log content."
- (interactive)
- (anvillm--refresh-log-buffer)
- (message "Log refreshed"))
-
-(defvar-local anvillm--prompt-session-id nil
- "Session ID for the current prompt buffer.")
-
-(defvar-local anvillm--reply-subject nil
- "Subject line for reply messages.")
-
-(defvar anvillm-prompt-mode-map
- (let ((map (make-sparse-keymap)))
- (define-key map (kbd "C-c C-c") #'anvillm-prompt-send)
- (define-key map (kbd "C-c C-k") #'anvillm-prompt-abort)
- map)
- "Keymap for AnviLLM prompt composition mode.")
-
-(define-derived-mode anvillm-prompt-mode text-mode "AnviLLM-Prompt"
- "Major mode for composing prompts to send to AnviLLM sessions.
-
-\\{anvillm-prompt-mode-map}"
- (setq header-line-format
- (substitute-command-keys
- "Compose prompt for AnviLLM session. Finish: \\[anvillm-prompt-send] | Abort: \\[anvillm-prompt-abort]")))
-
-(defun anvillm-compose-prompt ()
- "Open a buffer to compose a prompt for the selected session."
- (interactive)
- (if-let ((session-id (anvillm--get-selected-session)))
- (let* ((session-info (anvillm--get-session-info session-id))
- (display-name (or (plist-get session-info :alias)
- (substring session-id 0 (min 8 (length session-id)))))
- (buffer-name (format "*AnviLLM Prompt: %s*" display-name))
- (buffer (get-buffer-create buffer-name)))
- (with-current-buffer buffer
- (anvillm-prompt-mode)
- (erase-buffer)
- (setq anvillm--prompt-session-id session-id)
- (insert (format ";; Prompt for session: %s\n" display-name))
- (insert (format ";; Backend: %s\n" (plist-get session-info :backend)))
- (insert (format ";; State: %s\n\n" (plist-get session-info :state)))
- (insert ";; Type your prompt below (comments will be stripped).\n")
- (insert ";; Press C-c C-c to send, C-c C-k to abort.\n\n"))
- (pop-to-buffer buffer)
- (goto-char (point-max)))
- (message "No session selected")))
-
-(defun anvillm--get-session-info (session-id)
- "Get session information for SESSION-ID as a plist."
- (let ((backend (condition-case nil
- (string-trim (anvillm--9p-read
- (concat anvillm-agent-path "/" session-id "/backend")))
- (error "")))
- (state (condition-case nil
- (string-trim (anvillm--9p-read
- (concat anvillm-agent-path "/" session-id "/state")))
- (error "")))
- (alias (condition-case nil
- (string-trim (anvillm--9p-read
- (concat anvillm-agent-path "/" session-id "/alias")))
- (error ""))))
- (list :backend backend
- :state state
- :alias (if (string= alias "") nil alias))))
-
-(defun anvillm-send-prompt ()
- "Send a prompt to the selected session (using minibuffer)."
- (interactive)
- (if-let ((session-id (anvillm--get-selected-session)))
- (let* ((prompt (read-string "Prompt: "))
- (wrapped-prompt (anvillm--wrap-bead-id prompt)))
- (when (> (length prompt) 0)
- (condition-case err
- (let ((msg (json-encode `((to . ,session-id)
- (type . "PROMPT_REQUEST")
- (subject . "User prompt")
- (body . ,wrapped-prompt)))))
- (anvillm--9p-write (concat anvillm-agent-path "/user/mail") msg)
- (message "Sent prompt to %s" (substring session-id 0 (min 8 (length session-id)))))
- (error
- (message "Failed to send prompt: %s" (error-message-string err))))))
- (message "No session selected")))
-
-(defun anvillm-prompt-abort ()
- "Abort prompt composition and close the buffer."
- (interactive)
- (when (yes-or-no-p "Abort prompt composition? ")
- (quit-window t)))
-
-(defun anvillm--is-bead-id (text)
- "Check if TEXT matches bead ID pattern (e.g., bd-5xz or bd-5xz.1)."
- (string-match-p "^[a-zA-Z]+-[a-z0-9]+\\(\\.[0-9]+\\)*$" text))
-
-(defun anvillm--wrap-bead-id (prompt)
- "If PROMPT is a bead ID, wrap it with execution instructions."
- (let ((trimmed (string-trim prompt)))
- (if (anvillm--is-bead-id trimmed)
- (format "Load the beads skill, and work on bead %s." trimmed)
- prompt)))
-
-(defun anvillm--extract-prompt-text ()
- "Extract prompt text from buffer, stripping comment lines."
- (let ((lines (split-string (buffer-string) "\n")))
- (string-join
- (delq nil
- (mapcar (lambda (line)
- (unless (string-prefix-p ";;" (string-trim line))
- line))
- lines))
- "\n")))
-
-
-(defun anvillm-prompt-send ()
- "Send the composed prompt to the session and close the buffer."
- (interactive)
- (unless anvillm--prompt-session-id
- (error "No session ID associated with this buffer"))
- (let* ((prompt (anvillm--extract-prompt-text))
- (wrapped-prompt (anvillm--wrap-bead-id prompt))
- (subject (or anvillm--reply-subject "User prompt")))
- (if (string-empty-p (string-trim prompt))
- (message "Empty prompt, not sending")
- (condition-case err
- (let ((msg (json-encode `((to . ,anvillm--prompt-session-id)
- (type . "PROMPT_REQUEST")
- (subject . ,subject)
- (body . ,wrapped-prompt)))))
- (anvillm--9p-write (concat anvillm-agent-path "/user/mail") msg)
- (message "Sent prompt to %s"
- (substring anvillm--prompt-session-id 0
- (min 8 (length anvillm--prompt-session-id))))
- (quit-window t))
- (error
- (message "Failed to send prompt: %s" (error-message-string err)))))))
-
-(defun anvillm-mount-project ()
- "Mount a project directory as a beads mount."
- (interactive)
- (let* ((cwd (read-directory-name "Project directory: "))
- (name (read-string "Mount name: " (file-name-nondirectory (directory-file-name cwd)))))
- (when (and (> (length cwd) 0) (> (length name) 0))
- (condition-case err
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/beads/ctl")
- (format "mount %s %s" cwd name))
- (setq anvillm-current-mount name)
- (message "Mounted %s at %s" name cwd))
- (error
- (message "Failed to mount: %s" (error-message-string err)))))))
-
-(defun anvillm-unmount-project ()
- "Unmount a beads mount."
- (interactive)
- (let* ((mtab-data (condition-case nil
- (anvillm--9p-read (concat anvillm-agent-path "/beads/mtab"))
- (error "")))
- (lines (split-string (string-trim mtab-data) "\n"))
- (mounts (delq nil (mapcar (lambda (line)
- (when (> (length line) 0)
- (car (split-string line "\t"))))
- lines)))
- (mount (completing-read "Unmount: " mounts nil t)))
- (when (> (length mount) 0)
- (condition-case err
- (progn
- (anvillm--9p-write (concat anvillm-agent-path "/beads/ctl")
- (format "umount %s" mount))
- (message "Unmounted %s" mount))
- (error
- (message "Failed to unmount: %s" (error-message-string err)))))))
-
-(defun anvillm-select-mount ()
- "Select the current beads mount."
- (interactive)
- (let* ((mtab-data (condition-case nil
- (anvillm--9p-read (concat anvillm-agent-path "/beads/mtab"))
- (error "")))
- (lines (split-string (string-trim mtab-data) "\n"))
- (mounts (delq nil (mapcar (lambda (line)
- (when (> (length line) 0)
- (let ((parts (split-string line "\t")))
- (cons (format "%s (%s)" (car parts) (cadr parts))
- (car parts)))))
- lines)))
- (selection (completing-read "Select mount: " mounts nil t))
- (mount (cdr (assoc selection mounts))))
- (when mount
- (setq anvillm-current-mount mount)
- (message "Selected mount: %s" mount))))
-
-(provide 'anvillm)
-
-;;; anvillm.el ends here
diff --git a/mkfile b/mkfile
index cd26e7a..e4fcc3f 100644
--- a/mkfile
+++ b/mkfile
@@ -5,9 +5,6 @@ all:V: install
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
- 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
@@ -71,5 +68,4 @@ cron-remove:V:
clean:V:
rm -f $INSTALL_PATH/anvilsrv
- rm -f $INSTALL_PATH/anvilwebgw
rm -f $INSTALL_PATH/Assist