Fix 9p-read-inbox to strip .json extension when marking messages complete
This commit is contained in:
parent
241e4b9818
commit
35b3ff338e
|
|
@ -3,9 +3,9 @@ Prompt
|
|||
Q-Reset
|
||||
Q-Login
|
||||
/anvillm
|
||||
anvilweb
|
||||
/anvilweb
|
||||
.claude
|
||||
.kiro
|
||||
*.elc
|
||||
Assist
|
||||
anvilsrv
|
||||
/Assist
|
||||
/anvilsrv
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# Developer role configuration
|
||||
filesystem:
|
||||
ro:
|
||||
- "{HOME}/doc"
|
||||
rw:
|
||||
- "{CWD}"
|
||||
rwx:
|
||||
|
|
|
|||
|
|
@ -5,3 +5,64 @@ permissionMode: bypassPermissions
|
|||
---
|
||||
|
||||
You are an AI assistant for AnviLLM multi-agent workflows. You have full access to all tools and operate without permission restrictions.
|
||||
|
||||
## Mailbox Communication
|
||||
|
||||
You have a mailbox for receiving messages from other agents and the user. Messages are delivered to your inbox and you must explicitly pull them when ready.
|
||||
|
||||
### Checking Your Inbox
|
||||
|
||||
Your inbox is accessible via the `agent` namespace. Check it using the helper script:
|
||||
|
||||
```bash
|
||||
# Check for messages
|
||||
9p-read-inbox
|
||||
|
||||
# Read and mark complete in one step
|
||||
9p-read-inbox --complete
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
# Get your agent ID
|
||||
AGENT_ID=$(9p ls agent/ | head -1)
|
||||
|
||||
# List pending messages
|
||||
9p ls agent/$AGENT_ID/inbox/
|
||||
|
||||
# Read first message
|
||||
MSG=$(9p ls agent/$AGENT_ID/inbox/ | head -1)
|
||||
9p read agent/$AGENT_ID/inbox/$MSG
|
||||
|
||||
# After processing, mark complete
|
||||
9p rm agent/$AGENT_ID/inbox/$MSG
|
||||
```
|
||||
|
||||
### When to Check
|
||||
|
||||
- When transitioning to idle state (after completing a task)
|
||||
- When explicitly told to check messages
|
||||
- Periodically during long-running tasks
|
||||
|
||||
### Message Format
|
||||
|
||||
Messages are JSON with these fields:
|
||||
- `from`: Sender's session ID or "user"
|
||||
- `to`: Your session ID
|
||||
- `type`: Message type (QUESTION, REVIEW_REQUEST, STATUS_UPDATE, etc.)
|
||||
- `subject`: Brief subject line
|
||||
- `body`: Message content
|
||||
- `id`: Unique message ID
|
||||
- `timestamp`: When message was sent
|
||||
|
||||
### Sending Messages
|
||||
|
||||
To send a message to another agent or user:
|
||||
|
||||
```bash
|
||||
cat > /tmp/msg.json <<'ENDMSG'
|
||||
{"to":"recipient-id","type":"MESSAGE_TYPE","subject":"Subject","body":"Message body"}
|
||||
ENDMSG
|
||||
9p write agent/YOUR_ID/outbox/msg-$(date +%s).json < /tmp/msg.json
|
||||
```
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ fi
|
|||
echo "$(date '+%Y-%m-%d %H:%M:%S') - Found session ID: $session" >> /tmp/claude-hooks.log
|
||||
if echo idle | 9p write agent/$session/state 2>>/tmp/claude-hooks.log; then
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') - SUCCESS: Wrote 'idle' to agent/$session/state" >> /tmp/claude-hooks.log
|
||||
|
||||
# Check for pending messages in inbox
|
||||
if [ -n "$(9p ls agent/$session/inbox/ 2>/dev/null)" ]; then
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') - INBOX: Pending messages detected" >> /tmp/claude-hooks.log
|
||||
echo "[INBOX] You have pending messages. Check with: 9p-read-inbox"
|
||||
fi
|
||||
else
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') - ERROR: Failed to write to agent/$session/state" >> /tmp/claude-hooks.log
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -387,6 +387,21 @@ func sendPrompt(id, prompt string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func sendPromptDirect(id, prompt string) error {
|
||||
if !isConnected() {
|
||||
return fmt.Errorf("not connected to anvilsrv")
|
||||
}
|
||||
path := filepath.Join(id, "in")
|
||||
fid, err := fs.Open(path, plan9.OWRITE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fid.Close()
|
||||
|
||||
_, err = fid.Write([]byte(prompt))
|
||||
return err
|
||||
}
|
||||
|
||||
func setAlias(id, alias string) error {
|
||||
if !isConnected() {
|
||||
return fmt.Errorf("not connected to anvilsrv")
|
||||
|
|
@ -563,7 +578,7 @@ func openPromptWindow(sess *SessionInfo) (*acme.Win, error) {
|
|||
return nil, err
|
||||
}
|
||||
w.Name(name)
|
||||
w.Write("tag", []byte("Send "))
|
||||
w.Write("tag", []byte("Prompt Message "))
|
||||
w.Ctl("clean")
|
||||
|
||||
// Track window ID client-side
|
||||
|
|
@ -584,15 +599,22 @@ func handlePromptWindow(w *acme.Win, sess *SessionInfo) {
|
|||
}()
|
||||
|
||||
for e := range w.EventChan() {
|
||||
if (e.C2 == 'x' || e.C2 == 'X') && string(e.Text) == "Send" {
|
||||
cmd := string(e.Text)
|
||||
if (e.C2 == 'x' || e.C2 == 'X') && (cmd == "Prompt" || cmd == "Message") {
|
||||
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 prompt: %v\n", err)
|
||||
var sendErr error
|
||||
if cmd == "Prompt" {
|
||||
sendErr = sendPromptDirect(sess.ID, prompt)
|
||||
} else {
|
||||
sendErr = sendPrompt(sess.ID, prompt)
|
||||
}
|
||||
if sendErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to send %s: %v\n", strings.ToLower(cmd), sendErr)
|
||||
continue
|
||||
}
|
||||
w.Ctl("delete")
|
||||
|
|
|
|||
|
|
@ -377,6 +377,18 @@ func sendPrompt(id, prompt string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func sendPromptDirect(id, prompt string) error {
|
||||
path := filepath.Join(id, "in")
|
||||
fid, err := fs.Open(path, plan9.OWRITE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fid.Close()
|
||||
|
||||
_, err = fid.Write([]byte(prompt))
|
||||
return err
|
||||
}
|
||||
|
||||
func updateStatus(msg string) {
|
||||
statusBar.Clear()
|
||||
fmt.Fprintf(statusBar, " %s", msg)
|
||||
|
|
|
|||
|
|
@ -203,6 +203,8 @@ func handleSession(w http.ResponseWriter, r *http.Request) {
|
|||
case "POST":
|
||||
if action == "prompt" {
|
||||
sendPrompt(w, r, id)
|
||||
} else if action == "prompt-direct" {
|
||||
sendPromptDirect(w, r, id)
|
||||
} else if action == "ctl" {
|
||||
controlSession(w, r, id)
|
||||
} else {
|
||||
|
|
@ -373,6 +375,44 @@ func sendPrompt(w http.ResponseWriter, r *http.Request, id string) {
|
|||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func sendPromptDirect(w http.ResponseWriter, r *http.Request, id string) {
|
||||
var req struct {
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Prompt) == "" {
|
||||
http.Error(w, "Prompt cannot be empty", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fs, err := connect()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
path := filepath.Join(id, "in")
|
||||
fid, err := fs.Open(path, plan9.OWRITE)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer fid.Close()
|
||||
|
||||
if _, err := fid.Write([]byte(req.Prompt)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func controlSession(w http.ResponseWriter, r *http.Request, id string) {
|
||||
var req struct {
|
||||
Command string `json:"command"`
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ Format: id alias state pid cwd (whitespace-separated; often tabs)."
|
|||
entry))
|
||||
|
||||
(defun anvillm-send-prompt-minibuffer ()
|
||||
"Send a prompt to the selected session."
|
||||
"Send a prompt to the selected session via mailbox (queued)."
|
||||
(interactive)
|
||||
(if-let ((session-id (anvillm--get-selected-session)))
|
||||
(let ((prompt (read-string "Prompt: ")))
|
||||
|
|
@ -204,6 +204,20 @@ Format: id alias state pid cwd (whitespace-separated; often tabs)."
|
|||
(message "Failed to send prompt: %s" (error-message-string err))))))
|
||||
(message "No session selected")))
|
||||
|
||||
(defun anvillm-send-prompt-direct ()
|
||||
"Send a prompt directly to the selected session (immediate, may block)."
|
||||
(interactive)
|
||||
(if-let ((session-id (anvillm--get-selected-session)))
|
||||
(let ((prompt (read-string "Prompt (direct): ")))
|
||||
(when (> (length prompt) 0)
|
||||
(condition-case err
|
||||
(progn
|
||||
(anvillm--9p-write (concat anvillm-agent-path "/" session-id "/in") prompt)
|
||||
(message "Sent direct prompt to %s" (substring session-id 0 (min 8 (length session-id)))))
|
||||
(error
|
||||
(message "Failed to send direct prompt: %s" (error-message-string err))))))
|
||||
(message "No session selected")))
|
||||
|
||||
(defun anvillm-stop-session ()
|
||||
"Stop the selected session."
|
||||
(interactive)
|
||||
|
|
@ -315,6 +329,7 @@ Backends:
|
|||
(set-keymap-parent map tabulated-list-mode-map)
|
||||
(define-key map (kbd "s") #'anvillm-start-session)
|
||||
(define-key map (kbd "P") #'anvillm-send-prompt-minibuffer)
|
||||
(define-key map (kbd "M-P") #'anvillm-send-prompt-direct)
|
||||
(define-key map (kbd "t") #'anvillm-stop-session)
|
||||
(define-key map (kbd "R") #'anvillm-restart-session)
|
||||
(define-key map (kbd "K") #'anvillm-kill-session)
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ type Session struct {
|
|||
|
||||
// State machine
|
||||
idleCond *sync.Cond // Signals when state transitions to idle
|
||||
idleSince time.Time // Timestamp when session last transitioned to idle
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
|
@ -95,11 +96,23 @@ func (s *Session) TransitionTo(newState string) error {
|
|||
return s.transitionToLocked(newState)
|
||||
}
|
||||
|
||||
// IdleDuration returns how long the session has been idle.
|
||||
// Returns 0 if not currently idle.
|
||||
func (s *Session) IdleDuration() time.Duration {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state != "idle" || s.idleSince.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return time.Since(s.idleSince)
|
||||
}
|
||||
|
||||
func (s *Session) transitionToLocked(newState string) error {
|
||||
// Validate transition
|
||||
switch {
|
||||
case s.state == "starting" && newState == "idle":
|
||||
s.state = newState
|
||||
s.idleSince = time.Now()
|
||||
s.idleCond.Broadcast()
|
||||
return nil
|
||||
case s.state == "starting" && newState == "error":
|
||||
|
|
@ -110,6 +123,7 @@ func (s *Session) transitionToLocked(newState string) error {
|
|||
return nil
|
||||
case s.state == "running" && newState == "idle":
|
||||
s.state = newState
|
||||
s.idleSince = time.Now()
|
||||
s.idleCond.Broadcast()
|
||||
return nil
|
||||
case s.state == "running" && newState == "error":
|
||||
|
|
@ -119,6 +133,7 @@ func (s *Session) transitionToLocked(newState string) error {
|
|||
case s.state == "error" && newState == "idle":
|
||||
// Manual recovery from error state
|
||||
s.state = newState
|
||||
s.idleSince = time.Now()
|
||||
s.idleCond.Broadcast()
|
||||
return nil
|
||||
case s.state == "error" && newState == "starting":
|
||||
|
|
|
|||
|
|
@ -138,6 +138,42 @@ func (m *Manager) GetPendingMessages(sessionID string) ([]*Message, error) {
|
|||
return m.GetInbox(sessionID), nil
|
||||
}
|
||||
|
||||
// PullMessage retrieves and removes the first message from inbox
|
||||
func (m *Manager) PullMessage(sessionID string) (*Message, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
msgs := m.inboxes[sessionID]
|
||||
if len(msgs) == 0 {
|
||||
return nil, fmt.Errorf("no messages in inbox")
|
||||
}
|
||||
|
||||
msg := msgs[0]
|
||||
m.inboxes[sessionID] = msgs[1:]
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// PeekInbox returns first message without removing it
|
||||
func (m *Manager) PeekInbox(sessionID string) (*Message, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
msgs := m.inboxes[sessionID]
|
||||
if len(msgs) == 0 {
|
||||
return nil, fmt.Errorf("no messages in inbox")
|
||||
}
|
||||
|
||||
return msgs[0], nil
|
||||
}
|
||||
|
||||
// HasPendingMessages checks if inbox has messages
|
||||
func (m *Manager) HasPendingMessages(sessionID string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
return len(m.inboxes[sessionID]) > 0
|
||||
}
|
||||
|
||||
// CompleteMessage moves a message from inbox to completed
|
||||
func (m *Manager) CompleteMessage(sessionID, msgID string) error {
|
||||
m.mu.Lock()
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ const (
|
|||
qidRoot = iota
|
||||
qidCtl
|
||||
qidList
|
||||
qidStatus
|
||||
qidUser // user directory
|
||||
qidUserInbox // user/inbox
|
||||
qidUserOutbox // user/outbox
|
||||
|
|
@ -106,8 +107,9 @@ type Server struct {
|
|||
}
|
||||
|
||||
type connState struct {
|
||||
fids map[uint32]*fid
|
||||
mu sync.RWMutex
|
||||
fids map[uint32]*fid
|
||||
mu sync.RWMutex
|
||||
sessionID string // Track which session owns this connection
|
||||
}
|
||||
|
||||
type fid struct {
|
||||
|
|
@ -203,6 +205,8 @@ func (s *Server) handle(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|||
return s.read(cs, fc)
|
||||
case plan9.Twrite:
|
||||
return s.write(cs, fc)
|
||||
case plan9.Tremove:
|
||||
return s.remove(cs, fc)
|
||||
case plan9.Tstat:
|
||||
return s.stat(cs, fc)
|
||||
case plan9.Tclunk:
|
||||
|
|
@ -507,7 +511,7 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|||
}
|
||||
args := strings.Fields(input)
|
||||
if len(args) == 0 {
|
||||
return errFcall(fc, "usage: stop | restart | kill | refresh")
|
||||
return errFcall(fc, "usage: stop | restart | kill | refresh | complete <msg-id>")
|
||||
}
|
||||
switch args[0] {
|
||||
case "stop":
|
||||
|
|
@ -528,6 +532,15 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|||
if err := sess.Refresh(ctx); err != nil {
|
||||
return errFcall(fc, err.Error())
|
||||
}
|
||||
case "complete":
|
||||
if len(args) < 2 {
|
||||
return errFcall(fc, "usage: complete <msg-id>")
|
||||
}
|
||||
msgID := args[1]
|
||||
mailMgr := s.mgr.GetMailManager()
|
||||
if err := mailMgr.CompleteMessage(parts[0], msgID); err != nil {
|
||||
return errFcall(fc, err.Error())
|
||||
}
|
||||
default:
|
||||
return errFcall(fc, "unknown command")
|
||||
}
|
||||
|
|
@ -536,10 +549,12 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|||
|
||||
// /{id}/in - send prompt (async, non-blocking)
|
||||
if len(parts) == 2 && parts[1] == "in" {
|
||||
sess := s.mgr.Get(parts[0])
|
||||
sessID := parts[0]
|
||||
sess := s.mgr.Get(sessID)
|
||||
if sess == nil {
|
||||
return errFcall(fc, "session not found")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if _, err := sess.Send(ctx, input); err != nil {
|
||||
return errFcall(fc, err.Error())
|
||||
|
|
@ -593,7 +608,8 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|||
|
||||
// /{id}/state - set session state (with validation)
|
||||
if len(parts) == 2 && parts[1] == "state" {
|
||||
sess := s.mgr.Get(parts[0])
|
||||
sessID := parts[0]
|
||||
sess := s.mgr.Get(sessID)
|
||||
if sess == nil {
|
||||
return errFcall(fc, "session not found")
|
||||
}
|
||||
|
|
@ -617,6 +633,14 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|||
return errFcall(fc, fmt.Sprintf("invalid state transition: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Track this connection's session ID
|
||||
cs.mu.Lock()
|
||||
if cs.sessionID == "" {
|
||||
cs.sessionID = sessID
|
||||
}
|
||||
cs.mu.Unlock()
|
||||
|
||||
return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))}
|
||||
}
|
||||
|
||||
|
|
@ -624,6 +648,13 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|||
if len(parts) == 3 && parts[1] == "outbox" && strings.HasSuffix(parts[2], ".json") {
|
||||
sessID := parts[0]
|
||||
|
||||
// Track this connection's session ID
|
||||
cs.mu.Lock()
|
||||
if cs.sessionID == "" {
|
||||
cs.sessionID = sessID
|
||||
}
|
||||
cs.mu.Unlock()
|
||||
|
||||
// Parse JSON message
|
||||
msg, err := mailbox.FromJSON(fc.Data)
|
||||
if err != nil {
|
||||
|
|
@ -691,6 +722,45 @@ func (s *Server) stat(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|||
return &plan9.Fcall{Type: plan9.Rstat, Tag: fc.Tag, Stat: stat}
|
||||
}
|
||||
|
||||
func (s *Server) remove(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
||||
cs.mu.Lock()
|
||||
f, ok := cs.fids[fc.Fid]
|
||||
sessionID := cs.sessionID
|
||||
cs.mu.Unlock()
|
||||
if !ok {
|
||||
return errFcall(fc, "bad fid")
|
||||
}
|
||||
|
||||
// Only support removing inbox messages (marks them as completed)
|
||||
parts := strings.Split(strings.TrimPrefix(f.path, "/"), "/")
|
||||
if len(parts) == 3 && parts[1] == "inbox" && strings.HasSuffix(parts[2], ".json") {
|
||||
sessID := parts[0]
|
||||
msgID := strings.TrimSuffix(parts[2], ".json")
|
||||
|
||||
// Validate ownership: only allow removing from own inbox or user inbox
|
||||
if sessionID == "" || (sessID != "user" && sessID != sessionID) {
|
||||
return errFcall(fc, "permission denied: can only remove from own inbox")
|
||||
}
|
||||
|
||||
mailMgr := s.mgr.GetMailManager()
|
||||
if mailMgr == nil {
|
||||
return errFcall(fc, "mailbox not available")
|
||||
}
|
||||
|
||||
if err := mailMgr.CompleteMessage(sessID, msgID); err != nil {
|
||||
return errFcall(fc, err.Error())
|
||||
}
|
||||
|
||||
cs.mu.Lock()
|
||||
delete(cs.fids, fc.Fid)
|
||||
cs.mu.Unlock()
|
||||
|
||||
return &plan9.Fcall{Type: plan9.Rremove, Tag: fc.Tag}
|
||||
}
|
||||
|
||||
return errFcall(fc, "remove not supported for this file")
|
||||
}
|
||||
|
||||
func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
|
||||
var dirs []plan9.Dir
|
||||
|
||||
|
|
@ -703,6 +773,10 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
|
|||
Qid: plan9.Qid{Type: QTFile, Path: qidList}, Mode: 0444, Name: "list",
|
||||
Uid: "q", Gid: "q", Muid: "q",
|
||||
})
|
||||
dirs = append(dirs, plan9.Dir{
|
||||
Qid: plan9.Qid{Type: QTFile, Path: qidStatus}, Mode: 0444, Name: "status",
|
||||
Uid: "q", Gid: "q", Muid: "q",
|
||||
})
|
||||
dirs = append(dirs, plan9.Dir{
|
||||
Qid: plan9.Qid{Type: QTDir, Path: qidUser},
|
||||
Mode: plan9.DMDIR | 0555, Name: "user", Uid: "q", Gid: "q", Muid: "q",
|
||||
|
|
@ -891,6 +965,35 @@ func (s *Server) readFile(path string) string {
|
|||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
if path == "/status" {
|
||||
var lines []string
|
||||
mailMgr := s.mgr.GetMailManager()
|
||||
for _, id := range s.mgr.List() {
|
||||
sess := s.mgr.Get(id)
|
||||
if sess != nil {
|
||||
state := sess.State()
|
||||
idleSince := "-"
|
||||
inboxCount := 0
|
||||
|
||||
if tmuxSess, ok := sess.(*tmux.Session); ok {
|
||||
if state == "idle" {
|
||||
idleDuration := tmuxSess.IdleDuration()
|
||||
if idleDuration > 0 {
|
||||
idleSince = fmt.Sprintf("%ds", int(idleDuration.Seconds()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if mailMgr != nil {
|
||||
inboxCount = len(mailMgr.GetInbox(id))
|
||||
}
|
||||
|
||||
lines = append(lines, fmt.Sprintf("%s %s %s %d", id, state, idleSince, inboxCount))
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
|
||||
|
||||
// User message file: /user/mailbox/msg-*.json
|
||||
|
|
|
|||
|
|
@ -158,51 +158,31 @@ func (m *Manager) processMailboxes() {
|
|||
}
|
||||
}
|
||||
|
||||
// 3. Process inbound messages for idle sessions (one message at a time)
|
||||
// 3. Prompt idle agents with pending messages (after 30 seconds of idle)
|
||||
for _, sess := range sessions {
|
||||
if sess.State() != "idle" {
|
||||
continue
|
||||
}
|
||||
|
||||
messages, err := m.mailManager.GetPendingMessages(sess.ID())
|
||||
if err != nil || len(messages) == 0 {
|
||||
// Check if session has been idle for more than 30 seconds
|
||||
tmuxSess, ok := sess.(*tmux.Session)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Process first message only
|
||||
msg := messages[0]
|
||||
|
||||
// Format message for bot
|
||||
prompt := fmt.Sprintf("[Message from %s]\nType: %s\nSubject: %s\n\n%s",
|
||||
msg.From, msg.Type, msg.Subject, msg.Body)
|
||||
|
||||
// Send asynchronously - only move to completed after success
|
||||
_, err = sess.Send(context.Background(), prompt)
|
||||
if err != nil {
|
||||
msg.Retries++
|
||||
if msg.Retries > 3 {
|
||||
// Discard after 3 failed attempts
|
||||
m.mailManager.CompleteMessage(sess.ID(), msg.ID)
|
||||
fmt.Fprintf(os.Stderr, "Message %s from %s to %s failed after 3 retries, discarding\nSubject: %s\nBody: %s\n",
|
||||
msg.ID, msg.From, sess.ID(), msg.Subject, msg.Body)
|
||||
|
||||
// Notify sender
|
||||
if msg.From != "" && msg.From != "user" {
|
||||
if senderSess := m.Get(msg.From); senderSess != nil {
|
||||
if tmuxSess, ok := senderSess.(*tmux.Session); ok {
|
||||
notification := fmt.Sprintf("\n[DELIVERY FAILURE]\nFailed to deliver message to %s after 3 attempts.\nSubject: %s\nBody: %s\n\n",
|
||||
sess.ID(), msg.Subject, msg.Body)
|
||||
tmuxSess.AppendToChatLog("SYSTEM", notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Message stays in inbox for retry on next tick
|
||||
idleDuration := tmuxSess.IdleDuration()
|
||||
if idleDuration < 30*time.Second {
|
||||
continue
|
||||
}
|
||||
|
||||
// Success - move to completed
|
||||
m.mailManager.CompleteMessage(sess.ID(), msg.ID)
|
||||
// Check if inbox has messages
|
||||
if !m.mailManager.HasPendingMessages(sess.ID()) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Prompt agent to check inbox
|
||||
ctx := context.Background()
|
||||
sess.Send(ctx, "check your inbox with 9p-read-inbox")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
],
|
||||
"stop": [
|
||||
{
|
||||
"command": "KIRO_PID=$(ps -o ppid= -p $PPID | tr -d ' '); cat $(namespace)/anvillm-session-id-$KIRO_PID 2>/dev/null | xargs -I{} sh -c 'echo idle | 9p write agent/{}/state 2>/dev/null' || true"
|
||||
"command": "KIRO_PID=$(ps -o ppid= -p $PPID | tr -d ' '); cat $(namespace)/anvillm-session-id-$KIRO_PID 2>/dev/null | xargs -I{} sh -c 'echo idle | 9p write agent/{}/state 2>/dev/null; AGENT_ID=$(9p ls agent/ 2>/dev/null | head -1); [ -n \"$AGENT_ID\" ] && [ -n \"$(9p ls agent/$AGENT_ID/inbox/ 2>/dev/null)\" ] && echo \"[INBOX] You have pending messages. Check with: 9p-read-inbox\"' || true"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
2
mkfile
2
mkfile
|
|
@ -11,6 +11,8 @@ build:V:
|
|||
chmod 0755 $INSTALL_PATH/Alog
|
||||
cp scripts/anvillm-skills $INSTALL_PATH/anvillm-skills
|
||||
chmod 0755 $INSTALL_PATH/anvillm-skills
|
||||
cp scripts/9p-read-inbox $INSTALL_PATH/9p-read-inbox
|
||||
chmod 0755 $INSTALL_PATH/9p-read-inbox
|
||||
mkdir -p $INSTALL_PATH/Workflows
|
||||
cp workflows/* $INSTALL_PATH/Workflows
|
||||
chmod 0755 $INSTALL_PATH/Workflows/*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
#!/bin/bash
|
||||
# Read next message from bot's inbox and mark it complete
|
||||
# Usage: 9p-read-inbox [--help]
|
||||
|
||||
set -e
|
||||
|
||||
if [ "$1" = "--help" ]; then
|
||||
echo "Usage: 9p-read-inbox"
|
||||
echo "Read the next message from the bot's inbox and mark it complete"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get agent ID from namespace file
|
||||
KIRO_PID=$(ps -o ppid= -p $PPID | tr -d ' ')
|
||||
AGENT_ID=$(cat $(namespace)/anvillm-session-id-$KIRO_PID 2>/dev/null)
|
||||
|
||||
if [ -z "$AGENT_ID" ]; then
|
||||
echo "Error: Cannot determine agent ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
INBOX_PATH="agent/$AGENT_ID/inbox"
|
||||
|
||||
# List messages
|
||||
MESSAGES=$(9p ls "$INBOX_PATH" 2>/dev/null) || {
|
||||
echo "Error: Failed to list inbox" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ -z "$MESSAGES" ]; then
|
||||
echo "No messages"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read first message
|
||||
FIRST_MSG=$(echo "$MESSAGES" | head -1)
|
||||
MSG_JSON=$(9p read "$INBOX_PATH/$FIRST_MSG") || {
|
||||
echo "Error: Failed to read message" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Display the message body
|
||||
echo "$MSG_JSON" | jq -r '"[Message from \(.from)]", "Type: \(.type)", "Subject: \(.subject)", "", .body'
|
||||
|
||||
# Mark as complete by writing to ctl file (strip .json extension)
|
||||
MSG_ID="${FIRST_MSG%.json}"
|
||||
echo "complete $MSG_ID" | 9p write "agent/$AGENT_ID/ctl" || {
|
||||
echo "Warning: Failed to mark message as complete" >&2
|
||||
exit 1
|
||||
}
|
||||
|
|
@ -9,8 +9,20 @@ description: Query and maintain a local knowledge base of code insights and arch
|
|||
|
||||
Leverage accumulated knowledge in `~/doc/agent-kb` to avoid redundant code research. Add new entries when discoveries warrant preservation.
|
||||
|
||||
## Answer Priority Hierarchy
|
||||
|
||||
When answering user questions, follow this strict priority order:
|
||||
|
||||
1. **KB First** - Search `~/doc/agent-kb/` for relevant entries
|
||||
2. **Code Search** - If KB doesn't have the answer, search/inspect the codebase
|
||||
3. **Web Search** - If code doesn't provide clarity, research online with proper citations
|
||||
4. **Training Data** - Only as last resort to fill remaining gaps
|
||||
|
||||
**Always cite your source** - Tell the user which level you're answering from.
|
||||
|
||||
## When to Use
|
||||
|
||||
- **When user asks any question** - Check KB first before answering
|
||||
- Encountering unfamiliar code patterns, services, or architecture
|
||||
- When user asks questions outside training data that would require code research
|
||||
- Before extensive codebase research—check KB first
|
||||
|
|
@ -47,7 +59,12 @@ After significant code exploration, consider what was learned:
|
|||
grep -l "pattern" ~/doc/agent-kb/*.md
|
||||
```
|
||||
|
||||
3. Read relevant entries before researching code.
|
||||
3. Search by content before answering questions:
|
||||
```bash
|
||||
grep -il "keyword" ~/doc/agent-kb/*.md
|
||||
```
|
||||
|
||||
4. Read relevant entries before researching code.
|
||||
|
||||
## File Naming Convention
|
||||
|
||||
|
|
@ -79,7 +96,7 @@ Examples:
|
|||
tags: [tag1, tag2]
|
||||
identifier: YYYYMMDDThhmmss
|
||||
verified: YYYY-MM-DD
|
||||
source: code-inspection | documentation | verbal | inferred
|
||||
source: code-inspection | documentation | online | verbal | inferred
|
||||
---
|
||||
|
||||
# Title
|
||||
|
|
@ -94,6 +111,7 @@ Examples:
|
|||
- `documentation` - from internal docs (Confluence, etc.)
|
||||
- `online` - from external online documentation
|
||||
- `verbal` - expert opinion, discussion, tacit knowledge
|
||||
- `inferred` - derived from training data (lowest priority)
|
||||
|
||||
If a source has a URL (online or documentation), add a `## Sources` section at the bottom of the document with the citation(s).
|
||||
|
||||
|
|
@ -112,4 +130,4 @@ When knowledge becomes stale or incomplete:
|
|||
|
||||
## What Belongs in the KB
|
||||
|
||||
- **Only verifiable knowledge from existing code** - no design alternatives, proposals, or "we could do X" speculation. Code is the source of truth.
|
||||
- **Only verifiable knowledge from authoritative sources** - code inspection, official documentation, or properly cited research. No speculation, design alternatives, or "we could do X" proposals.
|
||||
|
|
|
|||
Reference in New Issue