diff --git a/internal/p9/server.go b/internal/p9/server.go index 9cadb37..75c535e 100644 --- a/internal/p9/server.go +++ b/internal/p9/server.go @@ -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) diff --git a/internal/p9/sessionstore.go b/internal/p9/sessionstore.go new file mode 100644 index 0000000..f34c400 --- /dev/null +++ b/internal/p9/sessionstore.go @@ -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()) +} diff --git a/internal/p9/store.go b/internal/p9/store.go index c9ece76..2583dc5 100644 --- a/internal/p9/store.go +++ b/internal/p9/store.go @@ -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.