Add roles 9P endpoint to server and support multiple tool directories

- Wire up roles filesystem in server.go
- Add /agent/roles directory with focus areas and help
- Support ANVILLM_TOOLS_DIR env var for multiple tool directories
- Update pcloudcc-testing skill documentation
This commit is contained in:
Levi Neely 2026-03-04 21:15:23 +01:00
parent 65e5081134
commit 485016127d
4 changed files with 147 additions and 21 deletions

View File

@ -85,6 +85,7 @@ const (
qidBeadsReady // beads/ready
qidTools // tools directory
qidSkills // skills directory
qidRoles // roles directory
qidSessionBase = 1000
qidPeersBase = 0x10000000 // peers/{id}/file
qidInboxBase = 0x20000000 // session/{id}/inbox
@ -94,6 +95,7 @@ const (
qidBeadsBase = 0x60000000 // beads/{id}/file
qidToolsBase = 0x70000000 // tools/{capability}/{tool}
qidSkillsBase = 0x80000000 // skills/{intent}/{skill}
qidRolesBase = 0x90000000 // roles/{focus-area}/{role}
)
// File indices within a session directory
@ -127,6 +129,7 @@ type Server struct {
beads *BeadsFS
tools *ToolsFS
skills *SkillsFS
roles *RolesFS
OnAliasChange func(backend.Session) // Called when session alias changes
mu sync.RWMutex
}
@ -183,6 +186,7 @@ func NewServer(mgr *session.Manager, beadsFS *BeadsFS) (*Server, error) {
beads: beadsFS,
tools: toolsFS,
skills: NewSkillsFS(),
roles: NewRolesFS(),
}
go s.acceptLoop()
return s, nil
@ -413,6 +417,9 @@ func (s *Server) walk(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
case "skills":
qid = plan9.Qid{Type: QTDir, Path: qidSkills}
newPath = "/skills"
case "roles":
qid = plan9.Qid{Type: QTDir, Path: qidRoles}
newPath = "/roles"
default:
// Check if it's a session ID
if sess := s.mgr.Get(name); sess != nil {
@ -517,6 +524,26 @@ func (s *Server) walk(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
// Inside skills/<intent>/<skill> - files
qid = plan9.Qid{Type: QTFile, Path: qidSkillsBase + hashID(path+name)}
newPath = path + "/" + name
} else if path == "/roles" {
// Inside roles directory - list focus areas + help
if s.roles != nil {
focusAreas, _ := s.roles.listFocusAreas()
qid = plan9.Qid{Type: QTFile, Path: qidRolesBase}
if name == "help" {
newPath = "/roles/help"
} else if slices.Contains(focusAreas, name) {
qid = plan9.Qid{Type: QTDir, Path: qidRolesBase + hashID(name)}
newPath = "/roles/" + name
} else {
return errFcall(fc, "not found")
}
} else {
return errFcall(fc, "not found")
}
} else if strings.HasPrefix(path, "/roles/") && strings.Count(path, "/") == 2 {
// Inside roles/<focus-area> - role files
qid = plan9.Qid{Type: QTFile, Path: qidRolesBase + hashID(path+name)}
newPath = path + "/" + name
} else if strings.HasPrefix(path, "/beads/") && strings.Count(path, "/") == 2 {
// Inside a mount directory
mountName := strings.TrimPrefix(path, "/beads/")
@ -1043,6 +1070,10 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
Qid: plan9.Qid{Type: QTDir, Path: qidSkills},
Mode: plan9.DMDIR | 0555, Name: "skills", Uid: "q", Gid: "q", Muid: "q",
})
dirs = append(dirs, plan9.Dir{
Qid: plan9.Qid{Type: QTDir, Path: qidRoles},
Mode: plan9.DMDIR | 0555, Name: "roles", Uid: "q", Gid: "q", Muid: "q",
})
for _, id := range s.mgr.List() {
dirs = append(dirs, plan9.Dir{
Qid: plan9.Qid{Type: QTDir, Path: qidSessionBase + hashID(id)},
@ -1116,6 +1147,34 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
})
}
}
} else if path == "/roles" {
if s.roles != nil {
// Add help file
dirs = append(dirs, plan9.Dir{
Qid: plan9.Qid{Type: QTFile, Path: qidRolesBase},
Mode: 0444, Name: "help", Uid: "q", Gid: "q", Muid: "q",
})
// Add focus area directories
focusAreas, _ := s.roles.listFocusAreas()
for _, fa := range focusAreas {
dirs = append(dirs, plan9.Dir{
Qid: plan9.Qid{Type: QTDir, Path: qidRolesBase + hashID(fa)},
Mode: plan9.DMDIR | 0555, Name: fa, Uid: "q", Gid: "q", Muid: "q",
})
}
}
} else if strings.HasPrefix(path, "/roles/") && strings.Count(path, "/") == 2 {
// Inside roles/<focus-area> - list role files
focusArea := strings.TrimPrefix(path, "/roles/")
if s.roles != nil {
roles, _ := s.roles.listRolesInFocusArea(focusArea)
for _, role := range roles {
dirs = append(dirs, plan9.Dir{
Qid: plan9.Qid{Type: QTFile, Path: qidRolesBase + hashID(path+role.Name)},
Mode: 0444, Name: role.Name + ".md", Uid: "q", Gid: "q", Muid: "q",
})
}
}
} else if path == "/beads" {
// Beads directory - only ctl, mtab, ready (aggregate), and mounted projects
dirs = append(dirs, plan9.Dir{
@ -1400,6 +1459,18 @@ func (s *Server) readFile(path string) string {
return ""
}
// Handle roles paths
if strings.HasPrefix(path, "/roles/") {
if s.roles != nil {
data, err := s.roles.Read("agent/" + strings.TrimPrefix(path, "/"))
if err != nil {
return ""
}
return string(data)
}
return ""
}
// Handle beads paths
if beadsPath, ok := strings.CutPrefix(path, "/beads/"); ok {
if s.beads != nil {

View File

@ -15,8 +15,8 @@ import (
// ToolsFS provides 9P filesystem access to agent tools organized by capability level.
// Tools are shell scripts with YAML front-matter defining their interface.
type ToolsFS struct {
tools []Tool
toolsDir string
tools []Tool
toolsDirs []string
}
// Tool represents an agent tool with its MCP-compatible schema.
@ -55,10 +55,28 @@ type ToolMeta struct {
}
func NewToolsFS(tools []Tool) *ToolsFS {
homeDir, _ := os.UserHomeDir()
var dirs []string
// Check ANVILLM_TOOLS_DIR first (colon-separated)
if envDirs := os.Getenv("ANVILLM_TOOLS_DIR"); envDirs != "" {
dirs = strings.Split(envDirs, ":")
} else {
// Default: Claude, Kiro, AnviLLM config directories
homeDir, _ := os.UserHomeDir()
var defaults []string
if claudeDir := os.Getenv("CLAUDE_CONFIG_DIR"); claudeDir != "" {
defaults = append(defaults, filepath.Join(claudeDir, "mcptools"))
}
defaults = append(defaults,
filepath.Join(homeDir, ".kiro/mcptools"),
filepath.Join(homeDir, ".config/anvillm/mcptools"),
)
dirs = defaults
}
return &ToolsFS{
tools: tools,
toolsDir: filepath.Join(homeDir, ".config/anvillm/mcptools"),
tools: tools,
toolsDirs: dirs,
}
}
@ -98,23 +116,31 @@ func parseFrontMatter(path string) (*ToolMeta, error) {
return meta, nil
}
// listAllTools scans toolsDir and returns metadata for all .sh files
// listAllTools scans all tools directories and returns metadata for all .sh files
func (t *ToolsFS) listAllTools() ([]*ToolMeta, error) {
entries, err := os.ReadDir(t.toolsDir)
if err != nil {
return nil, err
}
seen := make(map[string]bool)
var tools []*ToolMeta
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sh") {
continue
}
meta, err := parseFrontMatter(filepath.Join(t.toolsDir, entry.Name()))
for _, dir := range t.toolsDirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
tools = append(tools, meta)
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sh") {
continue
}
if seen[entry.Name()] {
continue
}
meta, err := parseFrontMatter(filepath.Join(dir, entry.Name()))
if err != nil {
continue
}
seen[entry.Name()] = true
tools = append(tools, meta)
}
}
return tools, nil
}
@ -266,9 +292,16 @@ func (t *ToolsFS) Read(path string) ([]byte, error) {
for _, tool := range tools {
if tool.Name == filename {
// Verify path stays within toolsDir
relPath, err := filepath.Rel(t.toolsDir, tool.Path)
if err != nil || strings.HasPrefix(relPath, "..") {
// Verify path stays within one of the tools directories
valid := false
for _, dir := range t.toolsDirs {
relPath, err := filepath.Rel(dir, tool.Path)
if err == nil && !strings.HasPrefix(relPath, "..") {
valid = true
break
}
}
if !valid {
return nil, fmt.Errorf("invalid tool path")
}
return os.ReadFile(tool.Path)

22
mcptools/list_roles.sh Executable file
View File

@ -0,0 +1,22 @@
#!/bin/bash
# capabilities: roles
# description: List all available roles (JSON array)
set -euo pipefail
# Verify running under landrun (test filesystem restriction)
if cat /etc/shadow >/dev/null 2>&1; then
echo "Error: This script must be run via execute_code tool" >&2
exit 1
fi
9p ls agent/roles 2>/dev/null | grep -v '^help$' | while read focus_area; do
9p ls "agent/roles/$focus_area" 2>/dev/null | while read role_file; do
role_name="${role_file%.md}"
desc=$(9p read "agent/roles/$focus_area/$role_file" 2>/dev/null | awk '/^---$/,/^---$/ {if (/^description:/) {sub(/^description: */, ""); print; exit}}')
printf '%s\t%s\t%s\n' "$focus_area" "$role_name" "$desc"
done
done | jq -Rs '
split("\n") | map(select(length > 0)) | map(
split("\t") | {focus_area: .[0], name: .[1], description: .[2]}
)
'

View File

@ -45,7 +45,7 @@ Load pcloudcc-testing skill. Test the affected code path with unit tests, fault
**Kill testing agent**: `bash <(9p read agent/tools/agents/kill_agent.sh) <testing-agent-id>`
**Note**: Use `execute_bash`, not `execute_code`
**Note**: `spawn_agent.sh` is an exception — use the Bash tool (`execute_bash`), NOT `execute_code`. All other commands in this section use `execute_code` as normal.
### Testing Agent Workflow