hierarchical SessionStore: /s/ is now a regular route entry

SessionStore accepts paths like id/file, id/t/tool, new, idx.
All /s/ dispatch logic removed from server handlers.
Remaining /s/ refs are metadata only (makeStat, fileOwnerGroup).

- SessionStore.Stat/Open/Delete/Rename/Create handle hierarchical paths
- SessionStore.ListDir for subdirectory listing
- Tool idx filtering moved into SessionStore.openEntry
- SessionStore.MkdirAll delegates to ToolStore
- Removed: sessionFileStore, sessionAllowTools, filterToolIndex,
  OpenFiltered, storeReadCtx, storeBlockingRead from server.go
- routeStore passes full suffix (not pathBase) to stores
This commit is contained in:
Levi Neely 2026-05-19 16:52:17 +02:00
parent 8f0dfdef37
commit 8c2f4a8754
2 changed files with 300 additions and 308 deletions

352
server.go
View File

@ -174,6 +174,11 @@ func (s *ToolStore) ListDir(rel string) ([]os.DirEntry, error) {
return os.ReadDir(filepath.Join(s.dir, rel))
}
// MkdirAll creates a directory relative to the tools root.
func (s *ToolStore) MkdirAll(rel string) error {
return os.MkdirAll(filepath.Join(s.dir, rel), 0755)
}
func (s *ToolStore) Stat(name string) (os.FileInfo, error) {
if name == "idx" {
return &syntheticFileInfo{Name_: "idx", Mode_: 0444}, nil
@ -201,25 +206,6 @@ func (s *ToolStore) Open(name string) (StoreEntry, error) {
return s.FlatDirStore.Open(name)
}
// OpenFiltered opens an entry, applying allowTools filtering to idx if non-nil.
func (s *ToolStore) OpenFiltered(name string, allowed map[string]bool) (StoreEntry, error) {
if name == "idx" && allowed != nil {
return &store.EntryConfig{
StatFn: func() (os.FileInfo, error) { return &syntheticFileInfo{Name_: "idx", Mode_: 0444}, nil },
ReadFn: func() ([]byte, error) {
data, err := s.index()
if err != nil {
return nil, err
}
return filterToolIndex(data, allowed), nil
},
WriteFn: func([]byte) error { return fmt.Errorf("idx: read-only") },
BlockingReadFn: func(context.Context, string) ([]byte, string, error) { return nil, "", fmt.Errorf("blocking read not supported") },
}, nil
}
return s.Open(name)
}
func (s *ToolStore) index() ([]byte, error) {
entries, err := s.FlatDirStore.List()
if err != nil {
@ -350,6 +336,7 @@ func New(sink *olog.Sink, opts ...ServerOption) *Server {
Sink: s.sink,
Strict: s.strict,
Yolo: s.yolo,
ToolStore: s.toolStore,
SaveTranscript: func(data []byte) error {
name := time.Now().Format("20060102T150405") + "-chat.md"
return storeWrite(s.transcriptStore, name, data)
@ -389,6 +376,7 @@ func (s *Server) storeRoutes() []storeRoute {
{"/a/", func() Store { return s.agentStore }},
{"/p/", func() Store { return s.promptStore }},
{"/m/", func() Store { return s.memStore }},
{"/s/", func() Store { return s.sessionStore }},
{"/sk/", func() Store { return s.skillStore }},
{"/u/", func() Store { return s.utilStore }},
{"/x/", func() Store { return s.pluginStore }},
@ -401,7 +389,7 @@ func (s *Server) storeRoutes() []storeRoute {
func (s *Server) routeStore(path string) (Store, string) {
for _, r := range s.storeRoutes() {
if strings.HasPrefix(path, r.prefix) {
return r.store(), pathBase(path)
return r.store(), strings.TrimPrefix(path, r.prefix)
}
}
// Root-level files (e.g. /backends, /help).
@ -411,41 +399,11 @@ func (s *Server) routeStore(path string) (Store, string) {
return s.rootStore, name
}
}
// Session store files (/s/sh, /s/bfg, etc.)
if isSessionStoreFile(path) {
return s.sessionStore, pathBase(path)
}
// Session paths: /s/{id}/{file} or /s/{id}/t/{tool}
if strings.HasPrefix(path, "/s/") {
parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 3)
if len(parts) >= 2 {
sessID := parts[0]
if parts[1] == "t" && len(parts) == 3 {
// Tool file: /s/{id}/t/{rel}
return s.toolStore, parts[2]
}
if parts[1] != "t" {
// Session file: /s/{id}/{file}
if sfs, ok := s.sessionFileStore(sessID); ok {
return sfs, parts[1]
}
}
}
}
return nil, ""
}
// openEntry opens a StoreEntry for the given path, applying any path-specific
// logic (e.g. tool idx filtering by session allowTools).
// openEntry opens a StoreEntry for the given path.
func (s *Server) openEntry(path string) (StoreEntry, error) {
// Tool idx with per-session filtering.
if strings.HasPrefix(path, "/s/") {
parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 3)
if len(parts) == 3 && parts[1] == "t" {
allowed := s.sessionAllowTools(parts[0])
return s.toolStore.OpenFiltered(parts[2], allowed)
}
}
st, name := s.routeStore(path)
if st == nil {
return nil, fmt.Errorf("%s: not found", path)
@ -580,43 +538,7 @@ func defaultTmpDir() string {
return paths.DataDir() + "/tmp"
}
// sessionFileStore returns a Store for the given session ID.
func (s *Server) sessionFileStore(sessID string) (store.RunnableStore, bool) {
st, err := s.sessionStore.OpenStore(sessID)
if err != nil {
return nil, false
}
return st, true
}
// sessionAllowTools returns the allowTools set for a session, or nil if unrestricted.
func (s *Server) sessionAllowTools(sessID string) map[string]bool {
sess := s.sessionStore.Session(sessID)
if sess == nil || len(sess.AllowTools) == 0 {
return nil
}
m := make(map[string]bool, len(sess.AllowTools))
for _, t := range sess.AllowTools {
m[t] = true
}
return m
}
// filterToolIndex filters a tool index (## name\n...\n\n sections) to only include allowed tools.
func filterToolIndex(data []byte, allowed map[string]bool) []byte {
var out []byte
for _, section := range strings.Split(string(data), "## ") {
if section == "" {
continue
}
name, _, _ := strings.Cut(section, "\n")
if allowed[name] {
out = append(out, "## "...)
out = append(out, section...)
}
}
return out
}
// storeRead opens an entry in a store and reads it.
func storeRead(s store.Store, name string) ([]byte, error) {
@ -785,43 +707,13 @@ func (s *Server) pathType(path string) string {
return "dir"
}
if st, name := s.routeStore(path); st != nil {
if _, err := st.Stat(name); err == nil {
return "file"
}
return ""
}
// Session store.
trimmed := strings.TrimPrefix(path, "/")
parts := strings.SplitN(trimmed, "/", 3)
switch {
case len(parts) == 1 && parts[0] == "s":
return "dir"
case len(parts) == 2 && parts[0] == "s":
if info, err := s.sessionStore.Stat(parts[1]); err == nil {
if info, err := st.Stat(name); err == nil {
if info.IsDir() {
return "dir"
}
return "file"
}
case len(parts) == 3 && parts[0] == "s":
if parts[2] == "t" {
if s.sessionStore.Session(parts[1]) != nil {
return "dir"
}
} else if strings.HasPrefix(parts[2], "t/") {
rel := strings.TrimPrefix(parts[2], "t/")
if info, err := s.toolStore.Stat(rel); err == nil {
if info.IsDir() {
return "dir"
}
return "file"
}
} else if sfs, ok := s.sessionFileStore(parts[1]); ok {
if _, err := sfs.Stat(parts[2]); err == nil {
return "file"
}
}
return ""
}
return ""
}
@ -958,51 +850,46 @@ func (s *Server) create(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
if !ok {
return errFcall(fc, "bad fid")
}
if fc.Perm&plan9.DMDIR != 0 {
// mkdir under session tool directory: /s/{sid}/t or /s/{sid}/t/...
if strings.HasPrefix(f.path, "/s/") {
parts := strings.SplitN(strings.TrimPrefix(f.path, "/s/"), "/", 3)
if (len(parts) == 2 && parts[1] == "t") || (len(parts) == 3 && parts[1] == "t") {
var rel string
if len(parts) == 2 {
rel = fc.Name
} else {
rel = parts[2] + "/" + fc.Name
}
dir := filepath.Join(s.toolStore.dir, rel)
if err := os.MkdirAll(dir, 0755); err != nil {
return errFcall(fc, err.Error())
}
newPath := pathJoin(f.path, fc.Name)
qid := plan9.Qid{Type: QTDir, Path: qidPath(newPath)}
f.path = newPath
f.qid = qid
f.mode = fc.Mode
return &plan9.Fcall{Type: plan9.Rcreate, Tag: fc.Tag, Qid: qid}
}
}
return errFcall(fc, "mkdir not supported")
}
newPath := pathJoin(f.path, fc.Name)
if fc.Perm&plan9.DMDIR != 0 {
// mkdir: delegate to store if it supports MkdirAll.
type dirCreator interface {
MkdirAll(string) error
}
st, name := s.routeStore(newPath)
if st == nil {
return errFcall(fc, "mkdir not supported")
}
if dc, ok := st.(dirCreator); ok {
if err := dc.MkdirAll(name); err != nil {
return errFcall(fc, err.Error())
}
} else {
return errFcall(fc, "mkdir not supported")
}
qid := plan9.Qid{Type: QTDir, Path: qidPath(newPath)}
f.path = newPath
f.qid = qid
f.mode = fc.Mode
return &plan9.Fcall{Type: plan9.Rcreate, Tag: fc.Tag, Qid: qid}
}
s.log.Debug("Tcreate parent=%q name=%q", f.path, fc.Name)
// Generic store-backed directories.
// File create: route to the parent directory's store.
if st := s.routeDir(f.path); st != nil {
if err := st.Create(fc.Name); err != nil {
return errFcall(fc, err.Error())
}
} else if strings.HasPrefix(f.path, "/s/") {
// Tool creates under session: /s/{sid}/t or /s/{sid}/t/...
parts := strings.SplitN(strings.TrimPrefix(f.path, "/s/"), "/", 3)
if (len(parts) == 2 && parts[1] == "t") || (len(parts) == 3 && parts[1] == "t") {
var rel string
if len(parts) == 2 {
rel = fc.Name
} else {
rel = parts[2] + "/" + fc.Name
}
if err := s.toolStore.Create(rel); err != nil {
} else if st, parentName := s.routeStore(f.path); st != nil {
// Parent is a subdirectory within a store; create relative to it.
type subCreator interface {
Create(string) error
}
if sc, ok := st.(subCreator); ok {
if err := sc.Create(parentName + "/" + fc.Name); err != nil {
return errFcall(fc, err.Error())
}
}
@ -1168,47 +1055,25 @@ func (s *Server) wstat(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag}
}
// Generic store-backed files.
if st, _ := s.routeStore(f.path); st != nil {
if err := st.Rename(oldName, newDir.Name); err != nil {
return errFcall(fc, err.Error())
}
// Update fid path: find the prefix and replace the base.
for _, r := range s.storeRoutes() {
if strings.HasPrefix(f.path, r.prefix) {
cs.mu.Lock()
f.path = r.prefix + newDir.Name
f.qid.Path = qidPath(f.path)
cs.mu.Unlock()
break
}
}
st, relPath := s.routeStore(f.path)
if st == nil {
return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag}
}
// Session paths.
if strings.HasPrefix(f.path, "/s/") {
parts := strings.SplitN(strings.TrimPrefix(f.path, "/s/"), "/", 3)
if len(parts) == 3 && parts[1] == "t" {
// Tool rename under session: /s/{sid}/t/{rel}
oldRel := parts[2]
parentRel := oldRel[:len(oldRel)-len(oldName)]
newRel := parentRel + newDir.Name
if err := s.toolStore.Rename(oldRel, newRel); err != nil {
return errFcall(fc, err.Error())
}
if err := st.Rename(relPath, newDir.Name); err != nil {
return errFcall(fc, err.Error())
}
// Update fid path.
for _, r := range s.storeRoutes() {
if strings.HasPrefix(f.path, r.prefix) {
// Replace the last path component with the new name.
parent := f.path[:len(f.path)-len(oldName)]
cs.mu.Lock()
f.path = "/s/" + parts[0] + "/t/" + newRel
f.path = parent + newDir.Name
f.qid.Path = qidPath(f.path)
cs.mu.Unlock()
} else if len(parts) == 1 && f.path != "/s/new" {
// Session directory rename: /s/{old} -> /s/{new}
if err := s.sessionStore.Rename(parts[0], newDir.Name); err != nil {
return errFcall(fc, err.Error())
}
break
}
}
return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag}
}
@ -1263,35 +1128,11 @@ func (s *Server) remove(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
path := f.path
s.log.Debug("Tremove path=%q", path)
// Generic store-backed files.
if st, name := s.routeStore(path); st != nil {
if err := st.Delete(name); err != nil {
return errFcall(fc, err.Error())
}
return &plan9.Fcall{Type: plan9.Rremove, Tag: fc.Tag}
}
var err error
switch {
case strings.HasPrefix(path, "/s/") && path != "/s/new":
parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 3)
if len(parts) >= 2 && (parts[1] == "t" || strings.HasPrefix(parts[1], "t/")) {
if len(parts) == 2 && parts[1] == "t" {
err = nil // can't remove the t/ dir itself
} else if len(parts) == 3 && parts[1] == "t" {
err = s.toolStore.Delete(parts[2])
} else {
err = nil
}
} else if len(parts) >= 2 {
err = nil // synthetic file; let rm -r continue
} else {
err = s.sessionStore.Delete(parts[0])
}
default:
st, name := s.routeStore(path)
if st == nil {
return errFcall(fc, "remove not supported")
}
if err != nil {
if err := st.Delete(name); err != nil {
return errFcall(fc, err.Error())
}
return &plan9.Fcall{Type: plan9.Rremove, Tag: fc.Tag}
@ -1323,15 +1164,6 @@ func (s *Server) InterruptAll() {
func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
var dirs []plan9.Dir
makeDir := func(name, fpath string, isDir bool, mode plan9.Perm) plan9.Dir {
q := plan9.Qid{Path: qidPath(fpath)}
if isDir {
q.Type = QTDir
}
uid, gid := s.fileOwnerGroup(fpath)
return plan9.Dir{Qid: q, Mode: mode, Name: name, Uid: uid, Gid: gid, Muid: uid}
}
if path == "/" {
rootEntries := []string{"a", "backends", "help", "m", "p", "s", "sk", "tmp", "u", "x", "tr"}
for _, name := range rootEntries {
@ -1340,6 +1172,7 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
dirs = append(dirs, st)
}
} else if st := s.routeDir(path); st != nil {
// Top-level store directory (e.g. /a, /s, /m).
entries, _ := st.List()
for _, e := range entries {
fpath := path + "/" + e.Name()
@ -1350,53 +1183,22 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
}
dirs = append(dirs, d)
}
} else if path == "/s" {
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))
} else if st, name := s.routeStore(path); st != nil {
// Subdirectory within a store (e.g. /s/mysession, /s/mysession/t).
type dirLister interface {
ListDir(string) ([]os.DirEntry, error)
}
} else if strings.HasPrefix(path, "/s/") {
parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 3)
if len(parts) >= 2 && parts[1] == "t" {
if len(parts) == 2 {
// /s/{sid}/t
entries, _ := s.toolStore.List()
allowed := s.sessionAllowTools(parts[0])
for _, e := range entries {
if e.Name() == "idx" {
dirs = append(dirs, makeDir(e.Name(), path+"/"+e.Name(), false, 0444))
continue
}
if allowed != nil && !allowed[e.Name()] {
continue
}
mode := plan9.Perm(0777)
dirs = append(dirs, makeDir(e.Name(), path+"/"+e.Name(), e.IsDir(), mode|plan9.Perm(boolToDir(e.IsDir()))))
}
} else {
// /s/{sid}/t/{subdir...}
rel := parts[2]
entries, _ := s.toolStore.ListDir(rel)
for _, e := range entries {
dirs = append(dirs, makeDir(e.Name(), path+"/"+e.Name(), e.IsDir(), 0777|plan9.Perm(boolToDir(e.IsDir()))))
if dl, ok := st.(dirLister); ok {
entries, _ := dl.ListDir(name)
for _, e := range entries {
fpath := path + "/" + e.Name()
d := s.makeStat(fpath)
if info, err := e.Info(); err == nil {
d.Atime = uint32(info.ModTime().Unix())
d.Mtime = uint32(info.ModTime().Unix())
}
dirs = append(dirs, d)
}
} else if len(parts) == 1 {
// /s/{sid} — session files + t/
sessID := parts[0]
if sfs, ok := s.sessionFileStore(sessID); ok {
entries, _ := sfs.List()
for _, e := range entries {
info, _ := e.Info()
dirs = append(dirs, makeDir(e.Name(), path+"/"+e.Name(), false, plan9.Perm(info.Mode())))
}
}
dirs = append(dirs, makeDir("t", path+"/t", true, plan9.DMDIR|0500))
}
}
@ -1607,13 +1409,9 @@ func (s *Server) makeStat(path string) plan9.Dir {
dir.Mtime = uint32(info.ModTime().Unix())
}
case strings.HasPrefix(path, "/s/"):
parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 2)
if len(parts) == 2 {
if sfs, ok := s.sessionFileStore(parts[0]); ok {
if info, err := sfs.Stat(parts[1]); err == nil {
dir.Length = uint64(info.Size())
}
}
name := strings.TrimPrefix(path, "/s/")
if info, err := s.sessionStore.Stat(name); err == nil {
dir.Length = uint64(info.Size())
}
}
}

View File

@ -111,6 +111,8 @@ type SessionStoreConfig struct {
// for protocol-level fixups (e.g. fid path rewriting).
OnRename func(oldID, newID string)
SaveTranscript func([]byte) error
// ToolStore provides access to the shared tool directory.
ToolStore Store
// NewCore, if non-nil, replaces the default backend.New + agent.NewAgentCore
// path. It receives the session ID, agent name, and cwd, and returns a Core.
NewCore func(sessionID, agentName, cwd string) (agent.Core, error)
@ -151,7 +153,7 @@ func NewSessionStore(cfg SessionStoreConfig) *SessionStore {
ListFn: ss.list,
OpenFn: ss.openEntry,
DeleteFn: ss.del,
CreateFn: func(string) error { return fmt.Errorf("create not supported for sessions") },
CreateFn: ss.create,
RenameFn: ss.renameSession,
}
return ss
@ -177,23 +179,85 @@ func (s *SessionStore) list() ([]os.DirEntry, error) {
return entries, nil
}
// ListDir lists entries in a subdirectory (e.g. "{id}", "{id}/t", "{id}/t/{subdir}").
func (s *SessionStore) ListDir(name string) ([]os.DirEntry, error) {
parts := strings.SplitN(name, "/", 3)
sessID := parts[0]
sess := s.Session(sessID)
if sess == nil {
return nil, fmt.Errorf("session not found: %s", sessID)
}
// {id} — list session files + t/
if len(parts) == 1 {
sfs, err := s.openStore(sess)
if err != nil {
return nil, err
}
entries, err := sfs.List()
if err != nil {
return nil, err
}
entries = append(entries, DirEntry("t", 0500))
return entries, nil
}
// {id}/t — list tools (filtered by allowTools)
if parts[1] == "t" && s.cfg.ToolStore != nil {
if len(parts) == 2 {
return s.cfg.ToolStore.List()
}
// {id}/t/{subdir} — delegate to ToolStore.ListDir if available
type listDirer interface {
ListDir(string) ([]os.DirEntry, error)
}
if ld, ok := s.cfg.ToolStore.(listDirer); ok {
return ld.ListDir(parts[2])
}
}
return nil, fmt.Errorf("%s: not a directory", name)
}
func (s *SessionStore) stat(name string) (os.FileInfo, error) {
// Top-level fixed files (new, idx, sh, etc.)
if mode, ok := sessionStoreFiles[name]; ok {
return &SyntheticFileInfo{Name_: name, Mode_: mode}, nil
}
parts := strings.SplitN(name, "/", 3)
sessID := parts[0]
s.mu.RLock()
_, ok := s.sessions[name]
sess, ok := s.sessions[sessID]
s.mu.RUnlock()
if ok {
return &SyntheticFileInfo{Name_: name, Mode_: 0555, IsDir_: true}, nil
if !ok {
return nil, fmt.Errorf("%s: not found", name)
}
return nil, fmt.Errorf("%s: not found", name)
// Session directory: {id}
if len(parts) == 1 {
return &SyntheticFileInfo{Name_: sessID, Mode_: 0555, IsDir_: true}, nil
}
// Tools directory: {id}/t
if parts[1] == "t" {
if len(parts) == 2 {
return &SyntheticFileInfo{Name_: "t", Mode_: 0500, IsDir_: true}, nil
}
// Tool file: {id}/t/{rel}
if s.cfg.ToolStore != nil {
return s.cfg.ToolStore.Stat(parts[2])
}
return nil, fmt.Errorf("%s: not found", name)
}
// Session file: {id}/{file}
sfs, err := s.openStore(sess)
if err != nil {
return nil, err
}
return sfs.Stat(parts[1])
}
func (s *SessionStore) openEntry(name string) (StoreEntry, error) {
notBlocking := func(context.Context, string) ([]byte, string, error) {
return nil, "", fmt.Errorf("blocking read not supported")
}
// Top-level fixed files.
switch name {
case "new":
return &EntryConfig{
@ -213,30 +277,136 @@ func (s *SessionStore) openEntry(name string) (StoreEntry, error) {
WriteFn: func([]byte) error { return fmt.Errorf("idx: read-only") },
BlockingReadFn: notBlocking,
}, nil
default:
if _, ok := sessionStoreFiles[name]; ok {
return &EntryConfig{
StatFn: func() (os.FileInfo, error) { return &SyntheticFileInfo{Name_: name, Mode_: sessionStoreFiles[name]}, nil },
ReadFn: func() ([]byte, error) {
return s.cfg.ReadFile(paths.CfgDir() + "/scripts/s/" + name)
},
WriteFn: func([]byte) error { return fmt.Errorf("%s: not writable", name) },
BlockingReadFn: notBlocking,
}, nil
}
if _, ok := sessionStoreFiles[name]; ok {
return &EntryConfig{
StatFn: func() (os.FileInfo, error) { return &SyntheticFileInfo{Name_: name, Mode_: sessionStoreFiles[name]}, nil },
ReadFn: func() ([]byte, error) {
return s.cfg.ReadFile(paths.CfgDir() + "/scripts/s/" + name)
},
WriteFn: func([]byte) error { return fmt.Errorf("%s: not writable", name) },
BlockingReadFn: notBlocking,
}, nil
}
// Hierarchical paths: {id}/{file} or {id}/t/{tool}
parts := strings.SplitN(name, "/", 3)
sessID := parts[0]
sess := s.Session(sessID)
if sess == nil {
return nil, fmt.Errorf("%s: not found", name)
}
if len(parts) == 1 {
return nil, fmt.Errorf("%s: is a directory", name)
}
// Tool file: {id}/t/{rel}
if parts[1] == "t" {
if len(parts) < 3 {
return nil, fmt.Errorf("%s: is a directory", name)
}
rel := parts[2]
if s.cfg.ToolStore == nil {
return nil, fmt.Errorf("%s: no tool store", name)
}
// Apply per-session allowTools filtering on idx.
if rel == "idx" && len(sess.AllowTools) > 0 {
allowed := make(map[string]bool, len(sess.AllowTools))
for _, t := range sess.AllowTools {
allowed[t] = true
}
return s.openFilteredToolIdx(allowed)
}
return s.cfg.ToolStore.Open(rel)
}
// Session file: {id}/{file}
sfs, err := s.openStore(sess)
if err != nil {
return nil, err
}
return sfs.Open(parts[1])
}
func (s *SessionStore) openFilteredToolIdx(allowed map[string]bool) (StoreEntry, error) {
base, err := s.cfg.ToolStore.Open("idx")
if err != nil {
return nil, err
}
return &EntryConfig{
StatFn: base.Stat,
ReadFn: func() ([]byte, error) {
data, err := base.Read()
if err != nil {
return nil, err
}
return filterToolIdx(data, allowed), nil
},
WriteFn: base.Write,
BlockingReadFn: base.BlockingRead,
}, nil
}
func filterToolIdx(data []byte, allowed map[string]bool) []byte {
var out []byte
for _, section := range strings.Split(string(data), "## ") {
if section == "" {
continue
}
name, _, _ := strings.Cut(section, "\n")
if allowed[name] {
out = append(out, "## "...)
out = append(out, section...)
}
}
return nil, fmt.Errorf("%s: not found", name)
return out
}
func (s *SessionStore) create(name string) error {
// Tool create: {id}/t/{rel}
parts := strings.SplitN(name, "/", 3)
if len(parts) == 3 && parts[1] == "t" && s.cfg.ToolStore != nil {
return s.cfg.ToolStore.Create(parts[2])
}
return fmt.Errorf("create not supported: %s", name)
}
// MkdirAll creates a directory within the session's tool store.
func (s *SessionStore) MkdirAll(name string) error {
parts := strings.SplitN(name, "/", 3)
if len(parts) == 3 && parts[1] == "t" {
type mkdirAller interface {
MkdirAll(string) error
}
if md, ok := s.cfg.ToolStore.(mkdirAller); ok {
return md.MkdirAll(parts[2])
}
}
return fmt.Errorf("mkdir not supported: %s", name)
}
func (s *SessionStore) del(name string) error {
s.mu.RLock()
_, ok := s.sessions[name]
s.mu.RUnlock()
if ok {
s.KillSession(name)
return nil
parts := strings.SplitN(name, "/", 3)
sessID := parts[0]
// Delete session directory itself.
if len(parts) == 1 {
s.mu.RLock()
_, ok := s.sessions[sessID]
s.mu.RUnlock()
if ok {
s.KillSession(sessID)
return nil
}
return fmt.Errorf("session not found: %s", sessID)
}
return fmt.Errorf("session not found: %s", name)
// Delete tool file: {id}/t/{rel}
if parts[1] == "t" && len(parts) == 3 {
if s.cfg.ToolStore != nil {
return s.cfg.ToolStore.Delete(parts[2])
}
return fmt.Errorf("no tool store")
}
// Session files are synthetic; allow rm -r to continue.
return nil
}
// Session returns the session for the given ID, or nil.
@ -261,17 +431,22 @@ func (s *SessionStore) SessionByUname(uname string) *Session {
// OpenStore returns a RunnableStore for the given session ID.
func (s *SessionStore) OpenStore(id string) (RunnableStore, error) {
if sess := s.Session(id); sess != nil {
return NewSessionFileStore(
sess,
s.cfg.Log,
func() { s.KillSession(id) },
func(newID string) error { return s.Rename(id, newID) },
s.cfg.SaveTranscript,
), nil
return s.openStore(sess)
}
return nil, fmt.Errorf("session not found: %s", id)
}
func (s *SessionStore) openStore(sess *Session) (RunnableStore, error) {
id := sess.RunnableID()
return NewSessionFileStore(
sess,
s.cfg.Log,
func() { s.KillSession(id) },
func(newID string) error { return s.Rename(id, newID) },
s.cfg.SaveTranscript,
), nil
}
// InterruptAll interrupts every active session.
func (s *SessionStore) InterruptAll() {
s.mu.RLock()
@ -463,7 +638,26 @@ func (s *SessionStore) createSession(args []string) error {
return nil
}
func (s *SessionStore) renameSession(oldID, newID string) error {
func (s *SessionStore) renameSession(old, new string) error {
// Tool rename: {id}/t/{oldRel} -> newName
if strings.Contains(old, "/t/") {
parts := strings.SplitN(old, "/", 3)
if len(parts) == 3 && parts[1] == "t" && s.cfg.ToolStore != nil {
oldRel := parts[2]
// new is just the new base name; reconstruct full rel path.
oldBase := oldRel
if i := strings.LastIndex(oldRel, "/"); i >= 0 {
oldBase = oldRel[i+1:]
}
parentRel := oldRel[:len(oldRel)-len(oldBase)]
newRel := parentRel + new
return s.cfg.ToolStore.Rename(oldRel, newRel)
}
return fmt.Errorf("rename not supported: %s", old)
}
// Session rename: {oldID} -> {newID}
oldID := old
newID := new
s.mu.Lock()
sess, ok := s.sessions[oldID]