9p: complete SessionStore integration; /s/ fully backed by store

All /s/ directory operations now route through SessionStore:
- pathType uses sessionStore.Stat for /s/{name} entries
- readDir uses sessionStore.List for /s/ listing
- wstat uses sessionStore.Rename for session renames
- read/write/remove for /s/new and /s/{id} already updated

README.md changes excluded (unrelated to store work).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-14 21:46:45 +02:00
parent 60024ee917
commit 22382e821d
3 changed files with 156 additions and 66 deletions

View File

@ -141,8 +141,9 @@ type Server struct {
promptStore ReadableStore
planStore Store
memStore Store
toolStore Store
skillStore Store
toolStore Store
skillStore Store
sessionStore Store
}
// New creates a new Server.
@ -150,7 +151,7 @@ func New() *Server {
memDir := defaultMemDir()
os.MkdirAll(memDir, 0755) //nolint:errcheck
agentsDir := agent.DefaultAgentsDir()
return &Server{
s := &Server{
sessions: make(map[string]*session),
agentsDir: agentsDir,
sessionsDir: agent.DefaultSessionsDir(),
@ -161,6 +162,8 @@ func New() *Server {
toolStore: NewToolStore(),
skillStore: NewSkillStore(),
}
s.sessionStore = &SessionStore{srv: s}
return s
}
// defaultPlanDir returns the planning directory from OLLIE_PLAN_PATH or the default.
@ -287,10 +290,13 @@ func (s *Server) pathType(path string) string {
switch {
case len(parts) == 1 && parts[0] == "s":
return "dir"
case len(parts) == 2 && parts[0] == "s" && parts[1] == "new":
return "file"
case len(parts) == 2 && parts[0] == "s" && parts[1] == "idx":
return "file"
case len(parts) == 2 && parts[0] == "s":
if info, err := s.sessionStore.Stat(parts[1]); err == nil {
if info.IsDir() {
return "dir"
}
return "file"
}
case len(parts) == 1 && parts[0] == "a":
return "dir"
case len(parts) == 1 && parts[0] == "p":
@ -331,23 +337,11 @@ func (s *Server) pathType(path string) string {
if _, err := s.toolStore.Stat(parts[1]); err == nil {
return "file"
}
case len(parts) == 2 && parts[0] == "s":
s.mu.RLock()
_, ok := s.sessions[parts[1]]
s.mu.RUnlock()
if ok {
return "dir"
}
case len(parts) == 3 && parts[0] == "s":
s.mu.RLock()
_, ok := s.sessions[parts[1]]
s.mu.RUnlock()
if !ok {
return ""
}
switch parts[2] {
case "ctl", "prompt", "enqueue", "dequeue", "chat", "reply", "backend", "agent", "model", "models", "mcp", "state", "cwd", "usage", "ctxsz", "systemprompt":
return "file"
if store, ok := s.sessionFileStore(parts[1]); ok {
if _, err := store.Stat(parts[2]); err == nil {
return "file"
}
}
}
return ""
@ -515,27 +509,13 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
}
// s/new returns the session creation template.
if path == "/s/new" {
content := []byte("name=\ncwd=\nbackend=\nmodel=\nagent=\n")
return s.readSlice(fc, content)
}
// s/idx is a live index of all sessions: name state cwd backend model.
if path == "/s/idx" {
var sb strings.Builder
s.mu.RLock()
for id, sess := range s.sessions {
sess.mu.RLock()
state := sess.core.State()
cwd := sess.core.CWD()
be := sess.core.BackendName()
model := sess.core.ModelName()
sess.mu.RUnlock()
fmt.Fprintf(&sb, "%s\t%s\t%s\t%s\t%s\n", id, state, cwd, be, model)
// s/new and s/idx are served from the session store.
if path == "/s/new" || path == "/s/idx" {
content, err := s.sessionStore.Get(pathBase(path))
if err != nil {
return errFcall(fc, err.Error())
}
s.mu.RUnlock()
return s.readSlice(fc, []byte(sb.String()))
return s.readSlice(fc, content)
}
// backends is a static list of ollie-provided backends.
@ -756,7 +736,7 @@ func (s *Server) wstat(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
if len(parts) != 2 || parts[0] != "s" {
return errFcall(fc, "rename not supported")
}
if err := s.renameSession(parts[1], newDir.Name); err != nil {
if err := s.sessionStore.Rename(parts[1], newDir.Name); err != nil {
return errFcall(fc, err.Error())
}
@ -882,7 +862,7 @@ func (s *Server) remove(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
case strings.HasPrefix(path, "/t/"):
err = s.toolStore.Delete(pathBase(path))
case strings.HasPrefix(path, "/s/") && path != "/s/new":
s.killSession(pathBase(path))
err = s.sessionStore.Delete(pathBase(path))
default:
return errFcall(fc, "remove not supported")
}
@ -901,7 +881,7 @@ func (s *Server) handleWrite(path, input string) error {
}
if path == "/s/new" {
return s.handleNewSession(input)
return s.sessionStore.Put("new", []byte(input))
}
// Agent config writes go to the agent store.
@ -1219,13 +1199,15 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
dirs = append(dirs, makeDir(e.Name(), "/t/"+e.Name(), false, mode))
}
} else if path == "/s" {
dirs = append(dirs, makeDir("new", "/s/new", false, 0666))
dirs = append(dirs, makeDir("idx", "/s/idx", false, 0444))
s.mu.RLock()
for id := range s.sessions {
dirs = append(dirs, makeDir(id, "/s/"+id, true, plan9.DMDIR|0555))
entries, _ := s.sessionStore.List()
for _, e := range entries {
info, _ := e.Info()
perm := plan9.Perm(info.Mode() & 0777)
if e.IsDir() {
perm = plan9.DMDIR | perm
}
dirs = append(dirs, makeDir(e.Name(), "/s/"+e.Name(), e.IsDir(), perm))
}
s.mu.RUnlock()
} else {
// Session subdirectory: /s/{sessid}
sessID := pathBase(path)

View File

@ -0,0 +1,95 @@
package p9
import (
"fmt"
"os"
"strings"
)
// SessionStore implements Store for the /s/ directory.
// Entries are session IDs (directories) plus the synthetic files "new" and "idx".
// Put("new", data) creates a session; Delete(id) kills one; Rename renames one.
type SessionStore struct {
srv *Server
}
func (s *SessionStore) List() ([]os.DirEntry, error) {
entries := []os.DirEntry{
syntheticEntry("new", 0666),
syntheticEntry("idx", 0444),
}
s.srv.mu.RLock()
for id := range s.srv.sessions {
entries = append(entries, syntheticDirEntry(id, 0555))
}
s.srv.mu.RUnlock()
return entries, nil
}
func (s *SessionStore) Stat(name string) (os.FileInfo, error) {
switch name {
case "new":
return &syntheticFileInfo{name: "new", mode: 0666}, nil
case "idx":
return &syntheticFileInfo{name: "idx", mode: 0444}, nil
}
s.srv.mu.RLock()
_, ok := s.srv.sessions[name]
s.srv.mu.RUnlock()
if ok {
return &syntheticFileInfo{name: name, mode: 0555, isDir: true}, nil
}
return nil, fmt.Errorf("%s: not found", name)
}
func (s *SessionStore) Get(name string) ([]byte, error) {
switch name {
case "new":
return []byte("name=\ncwd=\nbackend=\nmodel=\nagent=\n"), nil
case "idx":
return s.index(), nil
}
return nil, fmt.Errorf("%s: not a readable file", name)
}
func (s *SessionStore) Put(name string, data []byte) error {
if name != "new" {
return fmt.Errorf("%s: not writable", name)
}
return s.srv.handleNewSession(strings.TrimSpace(string(data)))
}
func (s *SessionStore) Delete(name string) error {
s.srv.mu.RLock()
_, ok := s.srv.sessions[name]
s.srv.mu.RUnlock()
if !ok {
return fmt.Errorf("session not found: %s", name)
}
s.srv.killSession(name)
return nil
}
func (s *SessionStore) Create(name string) error {
return fmt.Errorf("create not supported for sessions")
}
func (s *SessionStore) Rename(oldName, newName string) error {
return s.srv.renameSession(oldName, newName)
}
func (s *SessionStore) index() []byte {
var sb strings.Builder
s.srv.mu.RLock()
for id, sess := range s.srv.sessions {
sess.mu.RLock()
state := sess.core.State()
cwd := sess.core.CWD()
be := sess.core.BackendName()
model := sess.core.ModelName()
sess.mu.RUnlock()
fmt.Fprintf(&sb, "%s\t%s\t%s\t%s\t%s\n", id, state, cwd, be, model)
}
s.srv.mu.RUnlock()
return []byte(sb.String())
}

View File

@ -8,33 +8,46 @@ import (
// syntheticFileInfo implements os.FileInfo for entries with no backing file.
type syntheticFileInfo struct {
name string
mode os.FileMode
size int64
name string
mode os.FileMode
size int64
isDir bool
}
func (f *syntheticFileInfo) Name() string { return f.name }
func (f *syntheticFileInfo) Size() int64 { return f.size }
func (f *syntheticFileInfo) Mode() os.FileMode { return f.mode }
func (f *syntheticFileInfo) ModTime() time.Time { return time.Time{} }
func (f *syntheticFileInfo) IsDir() bool { return false }
func (f *syntheticFileInfo) IsDir() bool { return f.isDir }
func (f *syntheticFileInfo) Sys() any { return nil }
// syntheticDirEntry implements os.DirEntry for entries with no backing file.
type syntheticDirEntry struct {
name string
mode os.FileMode
// syntheticEntryImpl implements os.DirEntry for entries with no backing file.
type syntheticEntryImpl struct {
name string
mode os.FileMode
isDir bool
}
func (e *syntheticDirEntry) Name() string { return e.name }
func (e *syntheticDirEntry) IsDir() bool { return false }
func (e *syntheticDirEntry) Type() os.FileMode { return 0 }
func (e *syntheticDirEntry) Info() (os.FileInfo, error) {
return &syntheticFileInfo{name: e.name, mode: e.mode}, nil
func (e *syntheticEntryImpl) Name() string { return e.name }
func (e *syntheticEntryImpl) IsDir() bool { return e.isDir }
func (e *syntheticEntryImpl) Type() os.FileMode {
if e.isDir {
return os.ModeDir
}
return 0
}
func (e *syntheticEntryImpl) Info() (os.FileInfo, error) {
return &syntheticFileInfo{name: e.name, mode: e.mode, isDir: e.isDir}, nil
}
// syntheticEntry returns a synthetic file DirEntry.
func syntheticEntry(name string, mode os.FileMode) os.DirEntry {
return &syntheticDirEntry{name: name, mode: mode}
return &syntheticEntryImpl{name: name, mode: mode}
}
// syntheticDirEntry returns a synthetic directory DirEntry.
func syntheticDirEntry(name string, mode os.FileMode) os.DirEntry {
return &syntheticEntryImpl{name: name, mode: mode, isDir: true}
}
// ReadableStore is a named collection of byte blobs supporting only read operations.