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 ![Architecture](docs/diagrams/architecture.svg?v=2) -**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 - - - - -
-

AnviLLM

-
- - Checking... -
- - -
- -
- - - -
-
-

Sessions

-
- - -
-
- -
-
- - - - - - - - -
-
- -

New Session

-
-
- - -
- - -
-
-
-
- - -
-
- -

Send Prompt

-
-
- -
- - -
-
-
-
- - -
-
- -

Edit Context

-
-
- -
- - -
-
-
-
- - -
-
- -

Set Alias

-
-
- -
- - -
-
-
-
- - -
-
- -

Reply

-
-
- - - - -
- - -
-
-
-
- - -
-
- -

Message

-
-
-
- - - - - -
-
-
- - -
-
- -

New Task

-
-
- - -
- - -
-
-
-
- - -
-
- -

Task Details

-
-
-
- -
-
-
- - -
-
- -

Select Task to Work On

-
-
-

Loading ready tasks...

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

Backends

Core Services

Clients

9P

9P

9P

HTTP

9P

Assist
(Acme)

TUI

Emacs

Web

anvilsrv
(9P daemon)

anvilmcp
(MCP server)

anvilwebgw
(API proxy)

Claude

Kiro

ollie

Ollama

\ No newline at end of file +

Backends

Core Services

Frontends (any 9P client)

9P

9P

HTTP

9P

Assist
(Acme)

Custom
(your own)

Web App

anvilsrv
(9P daemon)

anvilmcp
(MCP server)

anvilwebgw
(HTTP↔9P bridge)

Claude

Kiro

ollie

Ollama

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

9p read
anvillm/list

9p write
anvillm/ctl

9p read
anvillm/events

Assist

TUI

Script

anvillm/
(9P filesystem)

\ No newline at end of file +

9p read
anvillm/list

9p write
anvillm/ctl

9p read
anvillm/events

Frontend
(any 9P client)

Script

anvillm/
(9P filesystem)

\ 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