session: add agent/{aid} sublevel routing

The 9P namespace now supports:
  session/{sid}/plan, env           (session-level)
  session/{sid}/agent/{aid}/*       (agent-level files)
  session/{sid}/agent/{aid}/proc/*  (agent detached processes)

Routing updated in stat, openEntry, and Readdir.
Existing flat access ({sid}/{file}) preserved for backward compat.
Tests pass unchanged.
This commit is contained in:
Levi Neely 2026-07-29 20:49:59 +02:00
parent 44a6dda695
commit 2a3798ae39
1 changed files with 94 additions and 34 deletions

View File

@ -131,29 +131,57 @@ func (s *Manager) list() ([]os.DirEntry, error) {
return entries, nil return entries, nil
} }
// Readdir lists entries in a subdirectory (e.g. "{id}", "{id}/peer"). // Readdir lists entries in a subdirectory.
func (s *Manager) Readdir(name string) ([]os.DirEntry, error) { func (s *Manager) Readdir(name string) ([]os.DirEntry, error) {
parts := strings.SplitN(name, "/", 3) parts := strings.SplitN(name, "/", 4)
sessID := parts[0] sessID := parts[0]
sess := s.Session(sessID) sess := s.Session(sessID)
if sess == nil { if sess == nil {
return nil, fmt.Errorf("session not found: %s", sessID) return nil, fmt.Errorf("session not found: %s", sessID)
} }
// {id} — list session files + peer/ // {id} — list session-level files + agent/
if len(parts) == 1 { if len(parts) == 1 {
sfs, err := s.openStore(sess) entries := []os.DirEntry{
if err != nil { fs.FileEntry("plan", 0666),
return nil, err fs.FileEntry("env", 0444),
fs.DirEntry("agent", 0755),
} }
entries, err := sfs.List()
if err != nil {
return nil, err
}
// Append peer/ and proc/ directories
entries = append(entries, fs.DirEntry("peer", 0755))
entries = append(entries, fs.DirEntry("proc", 0755))
return entries, nil return entries, nil
} }
// {id}/agent — list agent IDs
if parts[1] == "agent" {
if len(parts) == 2 {
// Currently single-agent: use the agent's ID
aid := sess.Core.Agent().ID()
if aid == "" {
aid = "0" // fallback for sessions without explicit agent ID
}
entries := []os.DirEntry{fs.DirEntry(aid, 0755)}
return entries, nil
}
// {id}/agent/{aid} — list agent files + proc/
if len(parts) == 3 {
afs, err := s.openAgentStore(sess)
if err != nil {
return nil, err
}
entries, err := afs.List()
if err != nil {
return nil, err
}
entries = append(entries, fs.DirEntry("proc", 0755))
return entries, nil
}
// {id}/agent/{aid}/proc — list detached process PIDs
if len(parts) == 4 && parts[3] == "proc" {
procs := sess.Core.ListDetached()
entries := make([]os.DirEntry, len(procs))
for i, p := range procs {
entries[i] = fs.FileEntry(fmt.Sprintf("%d", p.PID), 0666)
}
return entries, nil
}
}
// {id}/peer — list peer session IDs // {id}/peer — list peer session IDs
if len(parts) == 2 && parts[1] == "peer" { if len(parts) == 2 && parts[1] == "peer" {
peers, _ := s.PeerList(sessID) peers, _ := s.PeerList(sessID)
@ -163,15 +191,6 @@ func (s *Manager) Readdir(name string) ([]os.DirEntry, error) {
} }
return entries, nil return entries, nil
} }
// {id}/proc — list detached process PIDs
if len(parts) == 2 && parts[1] == "proc" {
procs := sess.Core.ListDetached()
entries := make([]os.DirEntry, len(procs))
for i, p := range procs {
entries[i] = fs.FileEntry(fmt.Sprintf("%d", p.PID), 0666)
}
return entries, nil
}
return nil, fmt.Errorf("%s: not a directory", name) return nil, fmt.Errorf("%s: not a directory", name)
} }
@ -275,7 +294,7 @@ func (s *Manager) stat(name string) (os.FileInfo, error) {
if mode, ok := fs.Perms[fs.PathSessions].Files[name]; ok { if mode, ok := fs.Perms[fs.PathSessions].Files[name]; ok {
return &fs.SyntheticFileInfo{Name_: name, Mode_: mode}, nil return &fs.SyntheticFileInfo{Name_: name, Mode_: mode}, nil
} }
parts := strings.SplitN(name, "/", 3) parts := strings.SplitN(name, "/", 4)
sessID := parts[0] sessID := parts[0]
s.mu.RLock() s.mu.RLock()
sess, ok := s.sessions[sessID] sess, ok := s.sessions[sessID]
@ -287,6 +306,25 @@ func (s *Manager) stat(name string) (os.FileInfo, error) {
if len(parts) == 1 { if len(parts) == 1 {
return &fs.SyntheticFileInfo{Name_: sessID, Mode_: fs.Perms[fs.PathSessionDir].DirMode, IsDir_: true}, nil return &fs.SyntheticFileInfo{Name_: sessID, Mode_: fs.Perms[fs.PathSessionDir].DirMode, IsDir_: true}, nil
} }
// Agent directory: {id}/agent
if parts[1] == "agent" {
if len(parts) == 2 {
return &fs.SyntheticFileInfo{Name_: "agent", Mode_: 0755, IsDir_: true}, nil
}
// Agent instance: {id}/agent/{aid}
if len(parts) == 3 {
return &fs.SyntheticFileInfo{Name_: parts[2], Mode_: 0755, IsDir_: true}, nil
}
// Agent file: {id}/agent/{aid}/{file}
if parts[3] == "proc" {
return &fs.SyntheticFileInfo{Name_: "proc", Mode_: 0755, IsDir_: true}, nil
}
afs, err := s.openAgentStore(sess)
if err != nil {
return nil, err
}
return afs.Stat(parts[3])
}
// Peer directory: {id}/peer // Peer directory: {id}/peer
if parts[1] == "peer" { if parts[1] == "peer" {
if len(parts) == 2 { if len(parts) == 2 {
@ -302,15 +340,7 @@ func (s *Manager) stat(name string) (os.FileInfo, error) {
} }
return &fs.SyntheticFileInfo{Name_: peerID, Mode_: 0666}, nil return &fs.SyntheticFileInfo{Name_: peerID, Mode_: 0666}, nil
} }
// Proc directory: {id}/proc // Session file: {id}/{file} (plan, env)
if parts[1] == "proc" {
if len(parts) == 2 {
return &fs.SyntheticFileInfo{Name_: "proc", Mode_: 0755, IsDir_: true}, nil
}
// Proc file: {id}/proc/{pid} — read-only output
return &fs.SyntheticFileInfo{Name_: parts[2], Mode_: 0444}, nil
}
// Session file: {id}/{file}
sfs, err := s.openStore(sess) sfs, err := s.openStore(sess)
if err != nil { if err != nil {
return nil, err return nil, err
@ -414,8 +444,8 @@ func (s *Manager) openEntry(name string) (fs.File, error) {
}, nil }, nil
} }
// Hierarchical paths: {id}/{file} or {id}/t/{tool} // Hierarchical paths: {id}/{file} or {id}/agent/{aid}/{file}
parts = strings.SplitN(name, "/", 3) parts = strings.SplitN(name, "/", 4)
sessID := parts[0] sessID := parts[0]
sess := s.Session(sessID) sess := s.Session(sessID)
if sess == nil { if sess == nil {
@ -424,6 +454,20 @@ func (s *Manager) openEntry(name string) (fs.File, error) {
if len(parts) == 1 { if len(parts) == 1 {
return nil, fmt.Errorf("%s: is a directory", name) return nil, fmt.Errorf("%s: is a directory", name)
} }
// Agent file: {id}/agent/{aid}/{file}
if parts[1] == "agent" {
if len(parts) < 4 {
return nil, fmt.Errorf("%s: is a directory", name)
}
// parts[2] = aid, parts[3] = file
afs, err := s.openAgentStore(sess)
if err != nil {
return nil, err
}
return afs.Open(parts[3])
}
// Session file: {id}/{file} // Session file: {id}/{file}
sfs, err := s.openStore(sess) sfs, err := s.openStore(sess)
if err != nil { if err != nil {
@ -523,6 +567,22 @@ func (s *Manager) openStore(sess *Session) (*fs.Tree, error) {
), nil ), nil
} }
func (s *Manager) openAgentStore(sess *Session) (*fs.Tree, error) {
var resetElev func()
if s.cfg.ResetElevation != nil {
id := sess.id
resetElev = func() { s.cfg.ResetElevation(id) }
}
return NewAgentTree(
sess,
s.cfg.Log,
nil, // no transcript saving
s.cfg.InvalidateModels,
resetElev,
s.cfg.ToolRegistry,
), nil
}
// InterruptAll interrupts every active session. // InterruptAll interrupts every active session.
func (s *Manager) InterruptAll() { func (s *Manager) InterruptAll() {
s.mu.RLock() s.mu.RLock()