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

2007 lines
49 KiB
Go

// Assist - Acme interface for AnviLLM
package main
import (
"anvillm/pkg/logging"
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"9fans.net/go/acme"
"9fans.net/go/plan9"
"9fans.net/go/plan9/client"
"go.uber.org/zap"
)
const windowName = "/AnviLLM/"
// Terminal to use for tmux attach (configurable via environment or flag)
var terminalCommand = getTerminalCommand()
// shellEscape escapes a string for use inside single quotes in shell commands.
func shellEscape(s string) string {
return strings.ReplaceAll(s, "'", "'\\''")
}
func getTerminalCommand() string {
if term := os.Getenv("ANVILLM_TERMINAL"); term != "" {
return term
}
return "foot" // default
}
type SessionInfo struct {
ID string
Backend string
Role string
State string
Alias string
Cwd string
Pid int
WinID int
}
var (
fs *client.Fsys
// Track window IDs for prompt windows (client-side state)
promptWindows = make(map[string]int) // session ID -> window ID
promptWindowsMu sync.RWMutex // protects promptWindows map
// Compile regex patterns once at startup
sessionIDRegex = regexp.MustCompile(`^[a-f0-9]{8}$`)
aliasRegex = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
)
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()
// Connect to anvillm via 9P, auto-starting if needed
var err error
fs, err = connectToServer()
if err != nil {
// Try to start anvillm automatically
logging.Logger().Info("anvillm not running, attempting to start")
startCmd := exec.Command("anvillm", "start")
if err := startCmd.Run(); err != nil {
logging.Logger().Error("failed to start anvillm", zap.Error(err))
logging.Logger().Info("continuing without daemon connection")
} else {
// Wait a moment for daemon to initialize
for i := 0; i < 20; i++ {
fs, err = connectToServer()
if err == nil {
break
}
time.Sleep(100 * time.Millisecond)
}
if err != nil {
logging.Logger().Error("failed to connect to anvillm after starting", zap.Error(err))
logging.Logger().Info("continuing without daemon connection")
}
}
}
if fs != nil {
defer fs.Close()
}
w, err := acme.New()
if err != nil {
logging.Logger().Fatal("failed to create acme window", zap.Error(err))
}
defer w.CloseFiles()
w.Name(windowName)
w.Write("tag", []byte("Get Put Attach Stop Restart Kill Alias Context Daemon Recover Inbox Archive "))
refreshList(w)
w.Ctl("clean")
// Event loop
for e := range w.EventChan() {
switch e.C2 {
case 'x', 'X':
cmd := string(e.Text)
arg := strings.TrimSpace(string(e.Arg))
// Handle Put: apply inline session edits from body
if cmd == "Put" {
body, err := w.ReadAll("body")
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading body: %v\n", err)
continue
}
edits := parseSessionEdits(string(body))
applySessionEdits(w, edits)
continue
}
// Handle session ID clicks
if sessionIDRegex.MatchString(cmd) {
if len(e.Arg) > 0 {
// Fire-and-forget: B2 on session ID with selected text sends prompt
go func(id, prompt string) {
if err := sendPrompt(id, prompt); err != nil {
fmt.Fprintf(os.Stderr, "Failed to send prompt: %v\n", err)
}
}(cmd, string(e.Arg))
} else {
// Middle-click on session ID without selection attaches to tmux
if err := attachSession(cmd); err != nil {
fmt.Fprintf(os.Stderr, "Failed to attach: %v\n", err)
}
}
continue
}
// Parse commands with arguments (e.g., "Kiro /path" -> cmd="Kiro", arg="/path")
cmd, arg = parseCommand(cmd, arg)
switch cmd {
case "Kiro":
if arg == "" {
fmt.Fprintf(os.Stderr, "Error: Kiro requires a path argument\n")
continue
}
if err := createSession("kiro-cli", arg); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
continue
}
refreshList(w)
case "Claude":
if arg == "" {
fmt.Fprintf(os.Stderr, "Error: Claude requires a path argument\n")
continue
}
if err := createSession("claude", arg); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
continue
}
refreshList(w)
case "Ollie":
if err := createSession("ollie", arg); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
continue
}
refreshList(w)
case "Stop":
if arg == "" {
fmt.Fprintf(os.Stderr, "Usage: Stop <session-id>\n")
continue
}
if err := controlSession(arg, "stop"); err != nil {
fmt.Fprintf(os.Stderr, "Failed to stop session: %v\n", err)
continue
}
refreshList(w)
case "Restart":
if arg == "" {
fmt.Fprintf(os.Stderr, "Usage: Restart <session-id>\n")
continue
}
if err := controlSession(arg, "restart"); err != nil {
fmt.Fprintf(os.Stderr, "Failed to restart session: %v\n", err)
continue
}
refreshList(w)
case "Kill":
if arg == "" {
fmt.Fprintf(os.Stderr, "Usage: Kill <session-id>\n")
continue
}
if err := controlSession(arg, "kill"); err != nil {
fmt.Fprintf(os.Stderr, "Failed to kill session: %v\n", err)
continue
}
refreshList(w)
case "Attach":
if arg == "" {
fmt.Fprintf(os.Stderr, "Usage: Attach <session-id>\n")
continue
}
if err := attachSession(arg); err != nil {
fmt.Fprintf(os.Stderr, "Failed to attach: %v\n", err)
}
case "Get":
refreshList(w)
case "Alias":
parts := strings.Fields(arg)
if len(parts) < 2 {
fmt.Fprintf(os.Stderr, "Usage: Alias <session-id> <name>\n")
continue
}
id := parts[0]
alias := parts[1]
if !aliasRegex.MatchString(alias) {
fmt.Fprintf(os.Stderr, "Invalid alias: must match [A-Za-z0-9_-]+\n")
continue
}
if err := setAlias(id, alias); err != nil {
fmt.Fprintf(os.Stderr, "Failed to set alias: %v\n", err)
continue
}
// Rename prompt window if it exists
promptWindowsMu.RLock()
winID, ok := promptWindows[id]
promptWindowsMu.RUnlock()
if ok {
if aw, err := acme.Open(winID, nil); err == nil {
sess, _ := getSession(id)
displayName := alias
if displayName == "" {
displayName = id
}
aw.Name(filepath.Join(sess.Cwd, fmt.Sprintf("+Prompt.%s", displayName)))
aw.CloseFiles()
}
}
refreshList(w)
case "Context":
if arg == "" {
fmt.Fprintf(os.Stderr, "Usage: Context <session-id>\n")
continue
}
sess, err := getSession(arg)
if err != nil {
fmt.Fprintf(os.Stderr, "Session not found: %s\n", arg)
continue
}
if err := openContextWindow(sess); err != nil {
fmt.Fprintf(os.Stderr, "Error opening context window: %v\n", err)
}
case "Daemon":
if err := openDaemonWindow(); err != nil {
fmt.Fprintf(os.Stderr, "Error opening daemon window: %v\n", err)
}
case "Recover":
if err := recoverSessions(w); err != nil {
fmt.Fprintf(os.Stderr, "Error recovering sessions: %v\n", err)
}
case "Inbox":
owner := "user"
if arg != "" {
owner = arg
}
if err := openInboxWindow(owner); err != nil {
fmt.Fprintf(os.Stderr, "Error opening inbox window: %v\n", err)
}
case "Archive":
date := time.Now().Format("20060102")
if len(arg) == 8 && isDigits(arg) {
date = arg
}
if err := openArchiveWindow("user", date); err != nil {
fmt.Fprintf(os.Stderr, "Error opening archive window: %v\n", err)
}
default:
w.WriteEvent(e)
}
case 'l', 'L':
text := strings.TrimSpace(string(e.Text))
if sessionIDRegex.MatchString(text) {
// Try to open/focus prompt window
promptWindowsMu.RLock()
winID, ok := promptWindows[text]
promptWindowsMu.RUnlock()
if ok {
if aw, err := acme.Open(winID, nil); err == nil {
aw.Ctl("show")
aw.CloseFiles()
} else {
// Window died, open new one
sess, _ := getSession(text)
if sess != nil {
openPromptWindow(sess)
}
}
} else {
// Open new prompt window
sess, _ := getSession(text)
if sess != nil {
openPromptWindow(sess)
}
}
} else {
w.WriteEvent(e)
}
default:
w.WriteEvent(e)
}
}
}
// parseCommand extracts command and argument from input text
// Handles cases like "Kiro /path" -> ("Kiro", "/path")
func parseCommand(cmd, arg string) (string, string) {
commandsWithArgs := []string{"Kiro", "Claude", "Ollie", "Stop", "Restart", "Kill", "Alias", "Context"}
for _, cmdName := range commandsWithArgs {
prefix := cmdName + " "
if strings.HasPrefix(cmd, prefix) {
return cmdName, strings.TrimPrefix(cmd, prefix)
}
}
return cmd, arg
}
func connectToServer() (*client.Fsys, error) {
ns := client.Namespace()
if ns == "" {
return nil, fmt.Errorf("no namespace")
}
// MountService expects just the service name, it adds the namespace automatically
return client.MountService("anvillm")
}
func isConnected() bool {
return fs != nil
}
func createSession(backend, cwd string) error {
if !isConnected() {
return fmt.Errorf("not connected to anvillm (use Daemon command to start server)")
}
// Validate and clean the path
cleanPath := filepath.Clean(cwd)
// Ensure it's an absolute path
if !filepath.IsAbs(cleanPath) {
var err error
cleanPath, err = filepath.Abs(cleanPath)
if err != nil {
return fmt.Errorf("invalid path: %v", err)
}
}
// Verify the directory exists
if info, err := os.Stat(cleanPath); err != nil {
return fmt.Errorf("path does not exist: %v", err)
} else if !info.IsDir() {
return fmt.Errorf("path is not a directory: %s", cleanPath)
}
fid, err := fs.Open("ctl", plan9.OWRITE)
if err != nil {
return err
}
defer fid.Close()
cmd := fmt.Sprintf("new %s %s", backend, cleanPath)
_, err = fid.Write([]byte(cmd))
return err
}
func controlSession(id, cmd string) error {
if !isConnected() {
return fmt.Errorf("not connected to anvillm")
}
path := filepath.Join(id, "ctl")
fid, err := fs.Open(path, plan9.OWRITE)
if err != nil {
return err
}
defer fid.Close()
_, err = fid.Write([]byte(cmd))
return err
}
func sendPrompt(id, prompt string) error {
if !isConnected() {
return fmt.Errorf("not connected to anvillm")
}
// 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
}
func setAlias(id, alias string) error {
if !isConnected() {
return fmt.Errorf("not connected to anvillm")
}
path := filepath.Join(id, "alias")
fid, err := fs.Open(path, plan9.OWRITE)
if err != nil {
return err
}
defer fid.Close()
_, err = fid.Write([]byte(alias))
return err
}
func getSession(id string) (*SessionInfo, error) {
// Read session metadata from 9P files
sess := &SessionInfo{ID: id}
// Read backend
if data, err := readFile(filepath.Join(id, "backend")); err == nil {
sess.Backend = strings.TrimSpace(string(data))
}
// Read state
if data, err := readFile(filepath.Join(id, "state")); err == nil {
sess.State = strings.TrimSpace(string(data))
}
// Read alias
if data, err := readFile(filepath.Join(id, "alias")); err == nil {
sess.Alias = strings.TrimSpace(string(data))
}
// Read cwd
if data, err := readFile(filepath.Join(id, "cwd")); err == nil {
sess.Cwd = strings.TrimSpace(string(data))
}
// Read pid
if data, err := readFile(filepath.Join(id, "pid")); err == nil {
fmt.Sscanf(string(data), "%d", &sess.Pid)
}
return sess, nil
}
func listSessions() ([]*SessionInfo, error) {
if !isConnected() {
return nil, fmt.Errorf("not connected to anvillm")
}
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 model cwd
fields := strings.Fields(line)
if len(fields) < 6 {
continue
}
sess := &SessionInfo{
ID: fields[0],
Backend: fields[1],
State: fields[2],
Alias: fields[3],
Role: fields[4],
Cwd: strings.Join(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) {
if !isConnected() {
return nil, fmt.Errorf("not connected to anvillm")
}
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 writeFile(path string, data []byte) error {
if !isConnected() {
return fmt.Errorf("not connected to anvillm")
}
fid, err := fs.Open(path, plan9.OWRITE)
if err != nil {
return err
}
defer fid.Close()
_, err = fid.Write(data)
return err
}
func refreshList(w *acme.Win) {
var buf strings.Builder
buf.WriteString("Backends: [Kiro] [Claude] [Ollie]\n\n")
if !isConnected() {
buf.WriteString("Not connected to anvillm daemon.\n")
buf.WriteString("Use 'Daemon' command to start the server.\n")
w.Addr(",")
w.Write("data", []byte(buf.String()))
w.Ctl("clean")
w.Addr("0")
w.Ctl("dot=addr")
w.Ctl("show")
return
}
sessions, err := listSessions()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to list sessions: %v\n", err)
buf.WriteString("Error listing sessions: " + err.Error() + "\n")
w.Addr(",")
w.Write("data", []byte(buf.String()))
w.Ctl("clean")
w.Addr("0")
w.Ctl("dot=addr")
w.Ctl("show")
return
}
buf.WriteString(fmt.Sprintf("%-8s %-10s %-16s %-9s %-16s %s\n", "ID", "Backend", "Role", "State", "Alias", "Cwd"))
buf.WriteString(fmt.Sprintf("%-8s %-10s %-16s %-9s %-16s %s\n", "--------", "----------", "----------------", "---------", "----------------", strings.Repeat("-", 40)))
for _, sess := range sessions {
alias := sess.Alias
if alias == "" {
alias = "-"
}
role := sess.Role
if role == "" {
role = "-"
}
buf.WriteString(fmt.Sprintf("%-8s %-10s %-16s %-9s %-16s %s\n", sess.ID, sess.Backend, role, sess.State, alias, sess.Cwd))
}
w.Addr(",")
w.Write("data", []byte(buf.String()))
w.Ctl("clean")
w.Addr("0")
w.Ctl("dot=addr")
w.Ctl("show")
}
func recoverSessions(w *acme.Win) error {
if !isConnected() {
return fmt.Errorf("not connected to anvillm")
}
if err := writeFile("ctl", []byte("recover")); err != nil {
return err
}
refreshList(w)
return nil
}
// sessionEdit represents a single inline action parsed from the sessions window body.
type sessionEdit struct {
action string // "kill", "stop", "alias", "start"
id string // session ID (for kill/stop/alias)
backend string // backend name (for start)
path string // working directory (for start)
alias string // alias name (for alias/start)
}
// parseSessionEdits parses inline action annotations from the sessions window body.
// Recognized prefixes (borrowed from the parseEdits pattern in permissions.go):
//
// - <id> Kill the session with that ID
// ~ <id> Stop the session (graceful)
// @ <id> <alias> Set alias on the session
// + <backend> <path> [alias] Start a new session (backends: claude, kiro-cli, ollie)
func parseSessionEdits(content string) []sessionEdit {
var edits []sessionEdit
validBackends := map[string]bool{
"claude": true,
"kiro-cli": true,
"ollie": true,
}
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if len(line) == 0 || strings.HasPrefix(line, "#") {
continue
}
switch {
case strings.HasPrefix(line, "- "):
parts := strings.Fields(line[2:])
if len(parts) >= 1 && sessionIDRegex.MatchString(parts[0]) {
edits = append(edits, sessionEdit{action: "kill", id: parts[0]})
}
case strings.HasPrefix(line, "~ "):
parts := strings.Fields(line[2:])
if len(parts) >= 1 && sessionIDRegex.MatchString(parts[0]) {
edits = append(edits, sessionEdit{action: "stop", id: parts[0]})
}
case strings.HasPrefix(line, "@ "):
parts := strings.Fields(line[2:])
if len(parts) >= 2 {
id := parts[0]
alias := parts[1]
if sessionIDRegex.MatchString(id) && aliasRegex.MatchString(alias) {
edits = append(edits, sessionEdit{action: "alias", id: id, alias: alias})
}
}
case strings.HasPrefix(line, "+ "):
parts := strings.Fields(line[2:])
if len(parts) >= 2 {
backend := parts[0]
path := parts[1]
if validBackends[backend] {
alias := ""
if len(parts) >= 3 && aliasRegex.MatchString(parts[2]) {
alias = parts[2]
}
edits = append(edits, sessionEdit{action: "start", backend: backend, path: path, alias: alias})
}
}
}
}
return edits
}
// applySessionEdits applies a batch of session edits atomically, then refreshes the list.
// For newly started sessions that need an alias, it retries asynchronously until the
// session appears, then sets the alias.
func applySessionEdits(w *acme.Win, edits []sessionEdit) {
if len(edits) == 0 {
refreshList(w)
return
}
// Snapshot existing session IDs before any starts so we can identify new ones.
existingSessions, _ := listSessions()
existingIDs := make(map[string]bool)
for _, s := range existingSessions {
existingIDs[s.ID] = true
}
// pendingAliases holds an alias string per start-action that requested one.
// "" means no alias wanted for that slot.
var pendingAliases []string
var errs []string
for _, edit := range edits {
switch edit.action {
case "kill":
if err := controlSession(edit.id, "kill"); err != nil {
errs = append(errs, fmt.Sprintf("kill %s: %v", edit.id, err))
}
case "stop":
if err := controlSession(edit.id, "stop"); err != nil {
errs = append(errs, fmt.Sprintf("stop %s: %v", edit.id, err))
}
case "alias":
if err := setAlias(edit.id, edit.alias); err != nil {
errs = append(errs, fmt.Sprintf("alias %s %s: %v", edit.id, edit.alias, err))
}
case "start":
if err := createSession(edit.backend, edit.path); err != nil {
errs = append(errs, fmt.Sprintf("start %s: %v", edit.backend, err))
} else {
pendingAliases = append(pendingAliases, edit.alias)
}
}
}
if len(errs) > 0 {
fmt.Fprintf(os.Stderr, "Session edit errors:\n%s\n", strings.Join(errs, "\n"))
}
// If any start-actions requested aliases, resolve them asynchronously: poll
// until the new sessions appear (up to ~10 s), assign aliases in FIFO order,
// then do a final list refresh.
if len(pendingAliases) > 0 {
go func() {
assigned := make([]bool, len(pendingAliases))
for attempt := 0; attempt < 20; attempt++ {
time.Sleep(500 * time.Millisecond)
sessions, err := listSessions()
if err != nil {
continue
}
// Collect new session IDs in appearance order.
var newSessions []*SessionInfo
for _, s := range sessions {
if !existingIDs[s.ID] {
newSessions = append(newSessions, s)
}
}
// Match pending aliases to new sessions in FIFO order.
newIdx := 0
for i, alias := range pendingAliases {
if assigned[i] {
newIdx++ // skip slots already resolved
continue
}
if alias == "" {
assigned[i] = true
continue
}
if newIdx < len(newSessions) {
if setAlias(newSessions[newIdx].ID, alias) == nil {
assigned[i] = true
}
newIdx++
}
}
// Check if all slots are resolved.
allDone := true
for _, done := range assigned {
if !done {
allDone = false
break
}
}
if allDone {
break
}
}
refreshList(w)
}()
}
refreshList(w)
}
func openPromptWindow(sess *SessionInfo) (*acme.Win, error) {
displayName := sess.Alias
if displayName == "" {
displayName = sess.ID
}
name := filepath.Join(sess.Cwd, fmt.Sprintf("+Prompt.%s", displayName))
w, err := acme.New()
if err != nil {
return nil, err
}
w.Name(name)
w.Write("tag", []byte("Send Compact Clear Resume "))
w.Ctl("clean")
// Track window ID client-side
promptWindowsMu.Lock()
promptWindows[sess.ID] = w.ID()
promptWindowsMu.Unlock()
go handlePromptWindow(w, sess)
return w, nil
}
func handlePromptWindow(w *acme.Win, sess *SessionInfo) {
defer w.CloseFiles()
defer func() {
promptWindowsMu.Lock()
delete(promptWindows, sess.ID)
promptWindowsMu.Unlock()
}()
for e := range w.EventChan() {
cmd := string(e.Text)
if e.C2 == 'x' || e.C2 == 'X' {
switch cmd {
case "Send":
body, err := w.ReadAll("body")
if err != nil {
continue
}
prompt := strings.TrimSpace(string(body))
if prompt != "" {
if err := sendPrompt(sess.ID, prompt); err != nil {
fmt.Fprintf(os.Stderr, "Failed to send: %v\n", err)
continue
}
w.Ctl("delete")
return
}
case "Compact":
if err := controlSession(sess.ID, "compact"); err != nil {
fmt.Fprintf(os.Stderr, "Failed to compact: %v\n", err)
}
case "Clear":
if err := controlSession(sess.ID, "clear"); err != nil {
fmt.Fprintf(os.Stderr, "Failed to clear: %v\n", err)
}
case "Resume":
if err := controlSession(sess.ID, "resume"); err != nil {
fmt.Fprintf(os.Stderr, "Failed to resume: %v\n", err)
}
default:
w.WriteEvent(e)
}
} else {
w.WriteEvent(e)
}
}
}
func openContextWindow(sess *SessionInfo) error {
w, err := acme.New()
if err != nil {
return err
}
w.Name(fmt.Sprintf("/AnviLLM/%s/context", sess.ID))
w.Write("tag", []byte("Put "))
// Load existing context
if data, err := readFile(filepath.Join(sess.ID, "context")); err == nil {
w.Write("body", data)
}
w.Ctl("clean")
go handleContextWindow(w, sess)
return nil
}
func handleContextWindow(w *acme.Win, sess *SessionInfo) {
defer w.CloseFiles()
for e := range w.EventChan() {
if e.C2 == 'x' || e.C2 == 'X' {
if string(e.Text) == "Put" {
body, err := w.ReadAll("body")
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading body: %v\n", err)
continue
}
path := filepath.Join(sess.ID, "context")
if err := writeFile(path, body); err != nil {
fmt.Fprintf(os.Stderr, "Error writing context: %v\n", err)
} else {
w.Ctl("clean")
logging.Logger().Info("context updated", zap.String("session", sess.ID))
fmt.Printf("Context updated for session %s\n", sess.ID)
}
continue
}
}
w.WriteEvent(e)
}
}
func attachSession(id string) error {
// Read tmux session/window from the tmux file
tmuxPath := filepath.Join(id, "tmux")
data, err := readFile(tmuxPath)
if err != nil {
return fmt.Errorf("failed to read tmux target: %w", err)
}
target := strings.TrimSpace(string(data))
if target == "" {
return fmt.Errorf("session does not support attach")
}
// Check if there's already a tmux client running
clientsCmd := exec.Command("tmux", "list-clients")
clientsOutput, err := clientsCmd.Output()
if err == nil && len(clientsOutput) > 0 {
// There's an existing tmux client, switch to the target session/window
go func() {
cmd := exec.Command("tmux", "switch-client", "-t", target)
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to switch tmux client: %v\n", err)
}
}()
} else {
// No existing client, launch new terminal
go func() {
cmd := exec.Command(terminalCommand, "-e", "tmux", "attach", "-t", target)
if err := cmd.Start(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to launch terminal: %v\n", err)
}
}()
}
return nil
}
// openDaemonWindow opens the daemon management window
func openDaemonWindow() error {
w, err := acme.New()
if err != nil {
return err
}
w.Name("/AnviLLM/Daemon")
w.Write("tag", []byte("Get Stop Start Restart "))
go handleDaemonWindow(w)
return nil
}
func handleDaemonWindow(w *acme.Win) {
defer w.CloseFiles()
// Load and display current status
refreshDaemonWindow(w)
for e := range w.EventChan() {
switch e.C2 {
case 'x', 'X':
cmd := string(e.Text)
switch cmd {
case "Get":
refreshDaemonWindow(w)
case "Stop":
stopDaemon(w)
time.Sleep(500 * time.Millisecond)
refreshDaemonWindow(w)
case "Start":
startDaemon(w)
time.Sleep(1 * time.Second)
refreshDaemonWindow(w)
case "Restart":
stopDaemon(w)
time.Sleep(500 * time.Millisecond)
startDaemon(w)
time.Sleep(1 * time.Second)
refreshDaemonWindow(w)
default:
w.WriteEvent(e)
}
default:
w.WriteEvent(e)
}
}
}
func refreshDaemonWindow(w *acme.Win) {
var buf strings.Builder
buf.WriteString("AnviLLM Daemon Status\n")
buf.WriteString(strings.Repeat("=", 60) + "\n\n")
// Check daemon status
statusCmd := exec.Command("anvillm", "status")
output, err := statusCmd.CombinedOutput()
if err != nil {
// Not running or error
buf.WriteString("Status: NOT RUNNING\n")
if len(output) > 0 {
buf.WriteString(string(output))
}
buf.WriteString("\n")
} else {
// Running
buf.WriteString("Status: RUNNING\n")
buf.WriteString(string(output))
buf.WriteString("\n")
}
// Check socket
ns := client.Namespace()
if ns != "" {
sockPath := filepath.Join(ns, "anvillm")
if _, err := os.Stat(sockPath); err == nil {
buf.WriteString("9P Socket: " + sockPath + " (exists)\n")
} else {
buf.WriteString("9P Socket: " + sockPath + " (missing)\n")
}
}
// Check connection
if fs != nil {
buf.WriteString("Connection: CONNECTED\n")
} else {
buf.WriteString("Connection: DISCONNECTED\n")
}
buf.WriteString("\n")
buf.WriteString(strings.Repeat("-", 60) + "\n")
buf.WriteString("Commands:\n")
buf.WriteString(" Start - Start the daemon (anvillm start)\n")
buf.WriteString(" Stop - Stop the daemon (anvillm stop)\n")
buf.WriteString(" Restart - Restart the daemon\n")
buf.WriteString(" Get - Refresh this window\n\n")
buf.WriteString("Note: Use 'anvillm fgstart' in terminal for debug logs.\n")
w.Addr(",")
w.Write("data", []byte(buf.String()))
w.Ctl("clean")
w.Addr("0")
w.Ctl("dot=addr")
w.Ctl("show")
}
func startDaemon(w *acme.Win) {
w.Addr("$")
w.Write("data", []byte("\nStarting daemon...\n"))
cmd := exec.Command("anvillm", "start")
output, err := cmd.CombinedOutput()
if err != nil {
w.Write("data", []byte(fmt.Sprintf("Error: %v\n%s\n", err, output)))
} else {
w.Write("data", []byte("Daemon started successfully\n"))
if len(output) > 0 {
w.Write("data", []byte(string(output)+"\n"))
}
// Try to reconnect
time.Sleep(500 * time.Millisecond)
if newFs, err := connectToServer(); err == nil {
if fs != nil {
fs.Close()
}
fs = newFs
w.Write("data", []byte("Reconnected to daemon\n"))
}
}
}
func stopDaemon(w *acme.Win) {
w.Addr("$")
w.Write("data", []byte("\nStopping daemon...\n"))
cmd := exec.Command("anvillm", "stop")
output, err := cmd.CombinedOutput()
if err != nil {
w.Write("data", []byte(fmt.Sprintf("Error: %v\n%s\n", err, output)))
} else {
w.Write("data", []byte("Daemon stopped\n"))
if len(output) > 0 {
w.Write("data", []byte(string(output)+"\n"))
}
// Disconnect
if fs != nil {
fs.Close()
fs = nil
}
}
}
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"`
}
func openInboxWindow(owner string) error {
w, err := acme.New()
if err != nil {
return err
}
if owner == "user" {
w.Name("/AnviLLM/inbox")
} else {
w.Name(fmt.Sprintf("/AnviLLM/%s/inbox", owner))
}
w.Write("tag", []byte("Get Put "))
go handleInboxWindow(w, owner)
return nil
}
func handleInboxWindow(w *acme.Win, owner string) {
defer w.CloseFiles()
folder := owner + "/inbox"
refreshMailboxWindow(w, folder, "Inbox")
for e := range w.EventChan() {
switch e.C2 {
case 'x', 'X':
switch string(e.Text) {
case "Get":
refreshMailboxWindow(w, folder, "Inbox")
case "Put":
messages, _, err := listMessages(folder)
if err != nil {
fmt.Fprintf(os.Stderr, "Error listing messages: %v\n", err)
continue
}
body, err := w.ReadAll("body")
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading body: %v\n", err)
continue
}
edits := parseMailEdits(string(body), messages)
var errs []string
for _, edit := range edits {
if err := deleteInboxMessage(edit.msgID); err != nil {
errs = append(errs, fmt.Sprintf("delete %s: %v", edit.msgID, err))
}
}
if len(errs) > 0 {
fmt.Fprintf(os.Stderr, "Put errors: %s\n", strings.Join(errs, "; "))
}
refreshMailboxWindow(w, folder, "Inbox")
default:
w.WriteEvent(e)
}
case 'l', 'L':
text := strings.TrimSpace(string(e.Text))
if isHexString(text) {
openMessageWindowByPrefix(text, folder)
} else {
w.WriteEvent(e)
}
default:
w.WriteEvent(e)
}
}
}
func refreshMailboxWindow(w *acme.Win, folder, title string) {
messages, _, err := listMessages(folder)
if err != nil {
w.Addr(",")
w.Write("data", []byte(fmt.Sprintf("Error reading %s: %v\n", title, err)))
w.Ctl("clean")
return
}
// Sort by timestamp, newest first
sort.Slice(messages, func(i, j int) bool {
return messages[i].Timestamp > messages[j].Timestamp
})
var buf strings.Builder
buf.WriteString(fmt.Sprintf("%s %s\n", folder, title))
buf.WriteString(strings.Repeat("=", 120) + "\n\n")
buf.WriteString(fmt.Sprintf("%-10s %-20s %-12s %-1s %-18s %s\n", "ID", "Date", "From", "!", "Type", "Subject"))
buf.WriteString(fmt.Sprintf("%-10s %-20s %-12s %-1s %-18s %s\n", "----------", "--------------------", "------------", "-", "------------------", strings.Repeat("-", 40)))
for _, msg := range messages {
from := msg.From
if from == "" {
from = "-"
}
shortID := shortUUID(msg.ID)
dateStr := formatTimestamp(msg.Timestamp)
// Mark messages that require user approval or review action
flag := " "
if msg.Type == "APPROVAL_REQUEST" || msg.Type == "REVIEW_REQUEST" {
flag = "!"
}
buf.WriteString(fmt.Sprintf("%-10s %-20s %-12s %-1s %-18s %s\n", shortID, dateStr, from, flag, msg.Type, msg.Subject))
}
w.Addr(",")
w.Write("data", []byte(buf.String()))
w.Ctl("clean")
w.Addr("0")
w.Ctl("dot=addr")
w.Ctl("show")
}
func shortUUID(id string) string {
if idx := strings.Index(id, "-"); idx > 0 {
return id[:idx]
}
return id
}
func expandUUID(shortID string, messages []Message) string {
for _, msg := range messages {
if strings.HasPrefix(msg.ID, shortID) {
return msg.ID
}
}
return shortID
}
func isDigits(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return len(s) > 0
}
func extractArchiveDate(tag string) string {
// tag format: "/AnviLLM/archive/20260318 Del Snarf ... | user tagline"
name := strings.Fields(tag)[0]
if strings.HasPrefix(name, "/AnviLLM/archive/") {
d := strings.TrimPrefix(name, "/AnviLLM/archive/")
if isDigits(d) {
return d
}
}
return ""
}
func openArchiveWindow(owner, date string) error {
w, err := acme.New()
if err != nil {
return err
}
if date != "" {
w.Name(fmt.Sprintf("/AnviLLM/archive/%s", date))
} else if owner == "user" {
w.Name("/AnviLLM/archive")
} else {
w.Name(fmt.Sprintf("/AnviLLM/%s/archive", owner))
}
w.Write("tag", []byte("Get Search "))
go handleArchiveWindow(w, owner, date)
return nil
}
func handleArchiveWindow(w *acme.Win, owner, date string) {
defer w.CloseFiles()
var messages []Message
var loadErr error
if date != "" {
messages, loadErr = loadMailHistory(owner, date)
} else {
messages, _, loadErr = listMessages(owner + "/completed")
}
refreshArchiveWindowWithMessages(w, messages, owner, date, loadErr)
for e := range w.EventChan() {
switch e.C2 {
case 'x', 'X':
cmd := string(e.Text)
arg := strings.TrimSpace(string(e.Arg))
switch cmd {
case "Get":
// Re-read date from window name
tag, _ := w.ReadAll("tag")
date = extractArchiveDate(string(tag))
messages = nil
if date != "" {
messages, loadErr = loadMailHistory(owner, date)
} else {
messages, _, loadErr = listMessages(owner + "/completed")
}
refreshArchiveWindowWithMessages(w, messages, owner, date, loadErr)
case "Search":
if arg == "" {
w.WriteEvent(e)
continue
}
out, err := searchMail("user", arg, "")
if err != nil {
w.Addr(",")
w.Write("data", []byte(fmt.Sprintf("Search error: %v\n", err)))
w.Ctl("clean")
continue
}
var searchResults []Message
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var entry struct {
Ts int64 `json:"ts"`
Data 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"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(line), &entry); err != nil {
continue
}
searchResults = append(searchResults, Message{
ID: entry.Data.ID,
From: entry.Data.From,
To: entry.Data.To,
Type: entry.Data.Type,
Subject: entry.Data.Subject,
Body: entry.Data.Body,
Timestamp: entry.Ts,
})
}
messages = searchResults
refreshArchiveWindowWithMessages(w, messages, owner, "", nil)
default:
w.WriteEvent(e)
}
case 'l', 'L':
text := strings.TrimSpace(string(e.Text))
if isHexString(text) {
openArchiveMessageByPrefix(text, messages)
} else {
w.WriteEvent(e)
}
default:
w.WriteEvent(e)
}
}
}
func loadMailHistory(owner, date string) ([]Message, error) {
agentID := owner
if agentID == "" {
agentID = "user"
}
mailDir := filepath.Join(os.Getenv("HOME"), ".local/share/anvillm/mail", agentID)
sentFile := filepath.Join(mailDir, date+"-sent.jsonl")
recvFile := filepath.Join(mailDir, date+"-recv.jsonl")
var messages []Message
for _, f := range []string{sentFile, recvFile} {
data, err := os.ReadFile(f)
if err != nil {
continue
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var entry struct {
Ts int64 `json:"ts"`
Data 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"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(line), &entry); err != nil {
continue
}
messages = append(messages, Message{
ID: entry.Data.ID,
From: entry.Data.From,
To: entry.Data.To,
Type: entry.Data.Type,
Subject: entry.Data.Subject,
Body: entry.Data.Body,
Timestamp: entry.Ts,
})
}
}
return messages, nil
}
func refreshArchiveWindowWithMessages(w *acme.Win, messages []Message, owner, date string, loadErr error) {
if loadErr != nil {
w.Addr(",")
w.Write("data", []byte(fmt.Sprintf("Error reading archive: %v\n", loadErr)))
w.Ctl("clean")
return
}
sort.Slice(messages, func(i, j int) bool {
return messages[i].Timestamp > messages[j].Timestamp
})
var buf strings.Builder
if date != "" {
buf.WriteString(fmt.Sprintf("Archive %s\n", date))
} else {
buf.WriteString(fmt.Sprintf("%s/completed Archive\n", owner))
}
buf.WriteString(strings.Repeat("=", 120) + "\n\n")
buf.WriteString(fmt.Sprintf("%-10s %-20s %-12s %-12s %-18s %s\n", "ID", "Date", "From", "To", "Type", "Subject"))
buf.WriteString(fmt.Sprintf("%-10s %-20s %-12s %-12s %-18s %s\n", "----------", "--------------------", "------------", "------------", "------------------", strings.Repeat("-", 40)))
for _, msg := range messages {
from := msg.From
if from == "" {
from = "-"
}
to := msg.To
if to == "" {
to = "-"
}
shortID := shortUUID(msg.ID)
dateStr := formatTimestamp(msg.Timestamp)
buf.WriteString(fmt.Sprintf("%-10s %-20s %-12s %-12s %-18s %s\n", shortID, dateStr, from, to, msg.Type, msg.Subject))
}
w.Addr(",")
w.Write("data", []byte(buf.String()))
w.Ctl("clean")
w.Addr("0")
w.Ctl("dot=addr")
w.Ctl("show")
}
func openArchiveMessageByPrefix(prefix string, messages []Message) error {
for _, m := range messages {
if strings.HasPrefix(m.ID, prefix) {
return openArchiveMessageWindow(&m)
}
}
return fmt.Errorf("message not found: %s", prefix)
}
func openArchiveMessageWindow(msg *Message) error {
w, err := acme.New()
if err != nil {
return err
}
w.Name(fmt.Sprintf("/AnviLLM/archive/%s", shortUUID(msg.ID)))
var buf strings.Builder
buf.WriteString(fmt.Sprintf("From: %s\n", msg.From))
buf.WriteString(fmt.Sprintf("To: %s\n", msg.To))
buf.WriteString(fmt.Sprintf("Type: %s\n", msg.Type))
buf.WriteString(fmt.Sprintf("Subject: %s\n", msg.Subject))
buf.WriteString(fmt.Sprintf("Date: %s\n", formatTimestamp(msg.Timestamp)))
buf.WriteString("\n")
buf.WriteString(msg.Body)
w.Write("body", []byte(buf.String()))
w.Ctl("clean")
return nil
}
func listInboxMessages() ([]Message, []string, error) {
return listMessages("user/inbox")
}
func listArchiveMessages() ([]Message, []string, error) {
return listMessages("user/completed")
}
func listMessages(folder string) ([]Message, []string, error) {
if !isConnected() {
return nil, nil, fmt.Errorf("not connected to anvillm")
}
fid, err := fs.Open(folder, plan9.OREAD)
if err != nil {
return nil, nil, err
}
defer fid.Close()
var filenames []string
for {
dirs, err := fid.Dirread()
if err != nil || len(dirs) == 0 {
break
}
for _, d := range dirs {
if strings.HasSuffix(d.Name, ".json") {
filenames = append(filenames, d.Name)
}
}
}
var messages []Message
for _, filename := range filenames {
data, err := readFile(filepath.Join(folder, filename))
if err != nil {
continue
}
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
continue
}
messages = append(messages, msg)
}
return messages, filenames, nil
}
func extractTimestamp(filename string) int64 {
var ts int64
parts := strings.Split(strings.TrimSuffix(filename, ".json"), "-")
if len(parts) == 2 {
fmt.Sscanf(parts[1], "%d", &ts)
}
return ts
}
func formatTimestamp(ts int64) string {
t := time.Unix(ts, 0)
return t.Format("02-Jan-2006 15:04:05")
}
func parseIndex(s string) int {
var idx int
fmt.Sscanf(s, "%d", &idx)
return idx
}
func isUUID(s string) bool {
// Simple UUID check: 36 chars with hyphens at positions 8, 13, 18, 23
if len(s) != 36 {
return false
}
for i, c := range s {
if i == 8 || i == 13 || i == 18 || i == 23 {
if c != '-' {
return false
}
} else if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
return true
}
func isHexString(s string) bool {
if len(s) < 4 || len(s) > 36 {
return false
}
for _, c := range s {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
return true
}
func openMessageWindowByPrefix(prefix, folder string) error {
messages, _, err := listMessages(folder)
if err != nil {
return err
}
for _, m := range messages {
if strings.HasPrefix(m.ID, prefix) {
return openMessageWindowByID(m.ID, folder)
}
}
return fmt.Errorf("message not found: %s", prefix)
}
func openMessageWindowByID(msgID, folder string) error {
messages, filenames, err := listMessages(folder)
if err != nil {
return err
}
var msg *Message
var filename string
for i, m := range messages {
if m.ID == msgID {
msg = &messages[i]
filename = filenames[i]
break
}
}
if msg == nil {
return fmt.Errorf("message not found: %s", msgID)
}
w, err := acme.New()
if err != nil {
return err
}
windowPath := "inbox"
if folder == "user/completed" {
windowPath = "archive"
}
w.Name(fmt.Sprintf("/AnviLLM/%s/%s", windowPath, filename))
// Add Approve/Reject buttons for messages requiring user action
tagStr := "Reply Archive "
if msg.Type == "APPROVAL_REQUEST" || msg.Type == "REVIEW_REQUEST" {
tagStr = "Approve Reject Reply Archive "
}
w.Write("tag", []byte(tagStr))
var buf strings.Builder
buf.WriteString(fmt.Sprintf("From: %s\n", msg.From))
buf.WriteString(fmt.Sprintf("To: %s\n", msg.To))
buf.WriteString(fmt.Sprintf("Type: %s\n", msg.Type))
buf.WriteString(fmt.Sprintf("Subject: %s\n", msg.Subject))
buf.WriteString(fmt.Sprintf("Date: %s\n", formatTimestamp(extractTimestamp(filename))))
buf.WriteString("\n")
buf.WriteString(msg.Body)
w.Write("body", []byte(buf.String()))
w.Ctl("clean")
go handleMessageWindow(w, msg, filename)
return nil
}
func openMessageWindow(idx int) error {
messages, filenames, err := listInboxMessages()
if err != nil {
return err
}
if idx < 1 || idx > len(messages) {
return fmt.Errorf("invalid index: %d", idx)
}
filename := filenames[idx-1]
msg := messages[idx-1]
w, err := acme.New()
if err != nil {
return err
}
w.Name(fmt.Sprintf("/AnviLLM/inbox/%s", filename))
w.Write("tag", []byte("Reply Archive "))
var buf strings.Builder
buf.WriteString(fmt.Sprintf("From: %s\n", msg.From))
buf.WriteString(fmt.Sprintf("To: %s\n", msg.To))
buf.WriteString(fmt.Sprintf("Type: %s\n", msg.Type))
buf.WriteString(fmt.Sprintf("Subject: %s\n", msg.Subject))
buf.WriteString(fmt.Sprintf("Date: %s\n", formatTimestamp(extractTimestamp(filename))))
buf.WriteString("\n")
buf.WriteString(msg.Body)
w.Write("body", []byte(buf.String()))
w.Ctl("clean")
go handleMessageWindow(w, &msg, filename)
return nil
}
func handleMessageWindow(w *acme.Win, msg *Message, filename string) {
defer w.CloseFiles()
for e := range w.EventChan() {
cmd := string(e.Text)
if e.C2 == 'x' || e.C2 == 'X' {
switch cmd {
case "Reply":
openReplyWindow(msg)
case "Approve":
openApproveWindow(msg)
case "Reject":
openRejectWindow(msg)
case "Archive":
if err := archiveMessage(msg.ID); err != nil {
w.Fprintf("errors", "Archive failed: %v\n", err)
} else {
refreshInboxWindowByName()
w.Ctl("delete")
}
default:
w.WriteEvent(e)
}
} else {
w.WriteEvent(e)
}
}
}
func openReplyWindow(originalMsg *Message) error {
w, err := acme.New()
if err != nil {
return err
}
replyType := getReplyType(originalMsg.Type)
replySubject := fmt.Sprintf("Re: %s", originalMsg.Subject)
w.Name(fmt.Sprintf("/AnviLLM/reply/%s", originalMsg.From))
w.Write("tag", []byte("Send "))
w.Ctl("clean")
// Pass "" for originalMsgID — plain replies do not auto-archive
go handleReplyWindow(w, originalMsg.From, replyType, replySubject, "")
return nil
}
// openApproveWindow opens a reply window pre-filled with "Approved." for one-click approval.
// On Send the original message is automatically archived.
func openApproveWindow(originalMsg *Message) error {
w, err := acme.New()
if err != nil {
return err
}
replyType := getReplyType(originalMsg.Type)
replySubject := fmt.Sprintf("Re: %s", originalMsg.Subject)
w.Name(fmt.Sprintf("/AnviLLM/reply/%s", originalMsg.From))
w.Write("tag", []byte("Send "))
w.Write("body", []byte("Approved."))
w.Ctl("clean")
go handleReplyWindow(w, originalMsg.From, replyType, replySubject, originalMsg.ID)
return nil
}
// openRejectWindow opens a reply window pre-filled with a rejection template,
// prompting the user to supply a reason before sending.
// On Send the original message is automatically archived.
func openRejectWindow(originalMsg *Message) error {
w, err := acme.New()
if err != nil {
return err
}
replyType := getReplyType(originalMsg.Type)
replySubject := fmt.Sprintf("Re: %s", originalMsg.Subject)
w.Name(fmt.Sprintf("/AnviLLM/reply/%s", originalMsg.From))
w.Write("tag", []byte("Send "))
w.Write("body", []byte("Rejected.\n\nReason: "))
w.Ctl("clean")
go handleReplyWindow(w, originalMsg.From, replyType, replySubject, originalMsg.ID)
return nil
}
func refreshInboxWindowByName() {
wins, err := acme.Windows()
if err != nil {
return
}
for _, info := range wins {
if info.Name == "/AnviLLM/inbox" {
w, err := acme.Open(info.ID, nil)
if err != nil {
continue
}
refreshMailboxWindow(w, "user/inbox", "Inbox")
w.CloseFiles()
return
}
}
}
func archiveMessage(msgID string) error {
if !isConnected() {
return fmt.Errorf("not connected to anvillm")
}
fid, err := fs.Open("user/ctl", plan9.OWRITE)
if err != nil {
return err
}
defer fid.Close()
ctlMsg := fmt.Sprintf("complete %s", msgID)
_, err = fid.Write([]byte(ctlMsg))
return err
}
// deleteArchivedMessage permanently removes a message from the completed/archive folder.
func deleteArchivedMessage(msgID string) error {
if !isConnected() {
return fmt.Errorf("not connected to anvillm")
}
fid, err := fs.Open("user/ctl", plan9.OWRITE)
if err != nil {
return err
}
defer fid.Close()
ctlMsg := fmt.Sprintf("delete %s", msgID)
_, err = fid.Write([]byte(ctlMsg))
return err
}
// deleteInboxMessage removes a message from the inbox without archiving.
func deleteInboxMessage(msgID string) error {
if !isConnected() {
return fmt.Errorf("not connected to anvillm")
}
fid, err := fs.Open("user/ctl", plan9.OWRITE)
if err != nil {
return err
}
defer fid.Close()
ctlMsg := fmt.Sprintf("delete %s", msgID)
_, err = fid.Write([]byte(ctlMsg))
return err
}
// mailEdit represents a single inline archive/delete action from a mailbox window body.
type mailEdit struct {
msgID string // full message ID (expanded from short-ID prefix)
}
// parseMailEdits parses inline action annotations from a mailbox window body.
// Lines starting with "- <shortID>" mark messages for bulk action (archive or delete).
func parseMailEdits(content string, messages []Message) []mailEdit {
var edits []mailEdit
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "- ") {
continue
}
parts := strings.Fields(line[2:])
if len(parts) >= 1 && isHexString(parts[0]) {
fullID := expandUUID(parts[0], messages)
edits = append(edits, mailEdit{msgID: fullID})
}
}
return edits
}
func getReplyType(msgType string) string {
switch msgType {
case "QUERY_REQUEST":
return "QUERY_RESPONSE"
case "REVIEW_REQUEST":
return "REVIEW_RESPONSE"
case "APPROVAL_REQUEST":
return "APPROVAL_RESPONSE"
default:
return "PROMPT_REQUEST"
}
}
func handleReplyWindow(w *acme.Win, to, msgType, subject, originalMsgID string) {
defer w.CloseFiles()
for e := range w.EventChan() {
if (e.C2 == 'x' || e.C2 == 'X') && string(e.Text) == "Send" {
body, err := w.ReadAll("body")
if err != nil {
continue
}
prompt := strings.TrimSpace(string(body))
if prompt != "" {
if err := sendReply(to, msgType, subject, prompt); err != nil {
fmt.Fprintf(os.Stderr, "Failed to send reply: %v\n", err)
continue
}
// Auto-archive the original message when replying from approve/reject
if originalMsgID != "" {
if err := archiveMessage(originalMsgID); err != nil {
fmt.Fprintf(os.Stderr, "Failed to archive original message: %v\n", err)
} else {
refreshInboxWindowByName()
}
}
w.Ctl("delete")
return
}
} else {
w.WriteEvent(e)
}
}
}
func sendReply(to, msgType, subject, body string) error {
if !isConnected() {
return fmt.Errorf("not connected to anvillm")
}
msg := map[string]interface{}{
"to": to,
"type": msgType,
"subject": subject,
"body": body,
}
msgJSON, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("failed to marshal message: %w", err)
}
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()
if _, err := fid.Write(msgJSON); err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
return nil
}
func searchMail(agentID, pattern, date string) ([]byte, error) {
homeDir, _ := os.UserHomeDir()
mailDir := filepath.Join(homeDir, ".local/share/anvillm/mail", agentID)
var files []string
if date != "" {
sent := filepath.Join(mailDir, date+"-sent.jsonl")
recv := filepath.Join(mailDir, date+"-recv.jsonl")
if _, err := os.Stat(sent); err == nil {
files = append(files, sent)
}
if _, err := os.Stat(recv); err == nil {
files = append(files, recv)
}
} else {
entries, _ := os.ReadDir(mailDir)
for _, e := range entries {
if strings.HasSuffix(e.Name(), "-sent.jsonl") || strings.HasSuffix(e.Name(), "-recv.jsonl") {
files = append(files, filepath.Join(mailDir, e.Name()))
}
}
}
re, err := regexp.Compile(pattern)
if err != nil {
return nil, err
}
var results []byte
for _, f := range files {
data, err := os.ReadFile(f)
if err != nil {
continue
}
for _, line := range strings.Split(string(data), "\n") {
if re.MatchString(line) {
results = append(results, line...)
results = append(results, '\n')
}
}
}
return results, nil
}