diff --git a/server.go b/server.go index 0ae466b..aba6fbd 100644 --- a/server.go +++ b/server.go @@ -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()) } } } diff --git a/store/session.go b/store/session.go index 9346b73..b33bc5e 100644 --- a/store/session.go +++ b/store/session.go @@ -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]