package fs import ( "context" "fmt" "os" "sort" "strconv" "strings" "sync" "sync/atomic" "ollie/agent" olog "ollie/log" coresession "ollie/session" "ollie/skills" "ollie/toolsrv" ) var rootFileOrder = []string{"new", "idx"} // FileMode returns the mode for a fixed session file, // or 0 and false if the name is not a fixed file. func FileMode(name string) (os.FileMode, bool) { m, ok := Perms[PathSessions].Files[name] return m, ok } // Config holds the dependencies for the session tree. type Config struct { Ctx context.Context // daemon context — sessions derive from this AgentsDir string SessionsDir string Log *olog.Logger Sink *olog.Sink ReadFile func(string) ([]byte, error) MkdirAll func(string, os.FileMode) error // NewCore, if non-nil, replaces the default backend.New + agent.New // path. It receives the session ID, agent name, and cwd, and returns a Session. NewCore func(sessionID, agentName, cwd string) (*coresession.Session, error) // Strict rejects inline code steps; only tool steps are allowed. Strict bool // Yolo skips the landrun sandbox. Yolo bool // NoMount disables the per-session FUSE mount (e.g. when listening on TCP). NoMount bool // Enable9P indicates the 9P listener is active (for operational model injection). Enable9P bool // EnableDBus indicates the D-Bus adapter is active (for operational model injection). EnableDBus bool // InvalidateModels clears the model cache, forcing a refresh. InvalidateModels func() // ToolRegistry is the shared tool registry for lazy tool promotion. ToolRegistry *toolsrv.Registry // SkillsRegistry is the shared skills registry for skill loading. SkillsRegistry *skills.Registry // OnSessionCreated is called after a new session is added. OnSessionCreated func(id string, sess *Session) // OnSessionKilled is called after a session is removed. OnSessionKilled func(id string) // OnSessionRenamed is called after a session is renamed. OnSessionRenamed func(oldID, newID string) } // rootState is the package-level state stored in root.Data. type rootState struct { cfg Config mu sync.RWMutex sessions map[string]*Session nextUID atomic.Uint32 } func (rs *rootState) nextUname() string { return fmt.Sprintf("%d", rs.nextUID.Add(1)) } // state extracts rootState from a tree's Data field. func state(root *Tree) *rootState { return root.Data.(*rootState) } // NewRoot creates the session tree with all callbacks wired up. // The returned tree IS the session collection. func NewRoot(cfg Config) *Tree { if cfg.ReadFile == nil { cfg.ReadFile = os.ReadFile } if cfg.MkdirAll == nil { cfg.MkdirAll = os.MkdirAll } rs := &rootState{ cfg: cfg, sessions: make(map[string]*Session), } rs.nextUID.Store(9999) restoreAllSessions(rs) tree := NewTree(nil, 0, WithStat(func(_ []string, name string) (os.FileInfo, error) { return rootStat(rs, name) }), WithOpener(func(_ []string, name string) (File, error) { return rootOpen(rs, name) }), WithLister(func(_ []string) ([]os.DirEntry, error) { return rootList(rs) }), WithReaddir(func(_ []string, name string) ([]os.DirEntry, error) { return rootReaddir(rs, name) }), WithDeleter(func(_ []string, name string) error { return rootDel(rs, name) }), WithCreator(func(_ []string, name string, _ os.FileMode) error { return fmt.Errorf("create not supported: %s", name) }), WithRenamer(func(_ []string, old, new string) error { return Rename(rs, old, new) }), ) tree.Data = rs return tree } // Lookup returns the session for the given ID, or nil. func Lookup(root *Tree, id string) *Session { rs := state(root) rs.mu.RLock() defer rs.mu.RUnlock() return rs.sessions[id] } // AddSession inserts a pre-built session (used by tests). func AddSession(root *Tree, sess *Session) { rs := state(root) rs.mu.Lock() rs.sessions[sess.RunnableID()] = sess rs.mu.Unlock() } // LookupByUname returns the session with the given uname (principal), or nil. func LookupByUname(root *Tree, uname string) *Session { rs := state(root) rs.mu.RLock() defer rs.mu.RUnlock() for _, sess := range rs.sessions { if sess.uname == uname { return sess } } return nil } // InterruptAll interrupts every active session. func InterruptAll(root *Tree) { rs := state(root) rs.mu.RLock() defer rs.mu.RUnlock() for _, sess := range rs.sessions { sess.Core.Agent().Interrupt(agent.ErrInterrupted) } } // OpenSessionTree returns a Tree for the given session ID. func OpenSessionTree(root *Tree, id string) (*Tree, error) { rs := state(root) sess := lookupLocked(rs, id) if sess == nil { return nil, fmt.Errorf("session not found: %s", id) } return openSessionTree(rs, sess), nil } func lookupLocked(rs *rootState, id string) *Session { rs.mu.RLock() defer rs.mu.RUnlock() return rs.sessions[id] } func openSessionTree(rs *rootState, sess *Session) *Tree { return NewSessionTree( sess, rs.cfg.Log, func() { Kill(rs, sess.id) }, func(newID string) error { return Rename(rs, sess.id, newID) }, nil, rs.cfg.InvalidateModels, rs.cfg.ToolRegistry, ) } func openAgentTree(rs *rootState, sess *Session) *Tree { return NewAgentTree( sess, rs.cfg.Log, nil, rs.cfg.InvalidateModels, rs.cfg.ToolRegistry, ) } // --- Tree callbacks --- func rootList(rs *rootState) ([]os.DirEntry, error) { entries := make([]os.DirEntry, 0, len(rootFileOrder)) for _, name := range rootFileOrder { entries = append(entries, FileEntry(name, Perms[PathSessions].Files[name])) } rs.mu.RLock() for id := range rs.sessions { entries = append(entries, DirEntry(id, Perms[PathSessionDir].DirMode)) } rs.mu.RUnlock() return entries, nil } func rootReaddir(rs *rootState, name string) ([]os.DirEntry, error) { parts := strings.SplitN(name, "/", 4) sessID := parts[0] rs.mu.RLock() sess := rs.sessions[sessID] rs.mu.RUnlock() if sess == nil { return nil, fmt.Errorf("session not found: %s", sessID) } if len(parts) == 1 { entries := []os.DirEntry{ FileEntry("plan", 0666), FileEntry("env", 0444), DirEntry("agent", 0755), } return entries, nil } if parts[1] == "agent" { if len(parts) == 2 { aid := sess.Core.Agent().ID() if aid == "" { aid = "0" } entries := []os.DirEntry{DirEntry(aid, 0755)} return entries, nil } if len(parts) == 3 { afs := openAgentTree(rs, sess) entries, err := afs.List() if err != nil { return nil, err } entries = append(entries, DirEntry("proc", 0755)) return entries, nil } if len(parts) == 4 && parts[3] == "proc" { procs := sess.Core.Agent().ListDetached() entries := make([]os.DirEntry, len(procs)) for i, p := range procs { entries[i] = FileEntry(fmt.Sprintf("%d", p.PID), 0666) } return entries, nil } } return nil, fmt.Errorf("%s: not a directory", name) } func rootStat(rs *rootState, name string) (os.FileInfo, error) { if mode, ok := Perms[PathSessions].Files[name]; ok { return &SyntheticFileInfo{Name_: name, Mode_: mode}, nil } parts := strings.SplitN(name, "/", 4) sessID := parts[0] rs.mu.RLock() sess, ok := rs.sessions[sessID] rs.mu.RUnlock() if !ok { return nil, fmt.Errorf("%s: not found", name) } if len(parts) == 1 { return &SyntheticFileInfo{Name_: sessID, Mode_: Perms[PathSessionDir].DirMode, IsDir_: true}, nil } if parts[1] == "agent" { if len(parts) == 2 { return &SyntheticFileInfo{Name_: "agent", Mode_: 0755, IsDir_: true}, nil } if len(parts) == 3 { return &SyntheticFileInfo{Name_: parts[2], Mode_: 0755, IsDir_: true}, nil } if parts[3] == "proc" { return &SyntheticFileInfo{Name_: "proc", Mode_: 0755, IsDir_: true}, nil } afs := openAgentTree(rs, sess) return afs.Stat(parts[3]) } sfs := openSessionTree(rs, sess) return sfs.Stat(parts[1]) } func rootOpen(rs *rootState, name string) (File, error) { notBlocking := func(context.Context, string) ([]byte, string, error) { return nil, "", fmt.Errorf("blocking read not supported") } parts := strings.SplitN(name, "/", 3) // Proc file: {id}/proc/{pid} if len(parts) == 3 && parts[1] == "proc" { sessID := parts[0] rs.mu.RLock() sess := rs.sessions[sessID] rs.mu.RUnlock() if sess == nil { return nil, fmt.Errorf("%s: not found", name) } pidStr := parts[2] pid, err := strconv.Atoi(pidStr) if err != nil { return nil, fmt.Errorf("invalid pid: %s", pidStr) } return &FileConfig{ StatFn: func() (os.FileInfo, error) { return &SyntheticFileInfo{Name_: pidStr, Mode_: 0444}, nil }, ReadFn: func() ([]byte, error) { output, err := sess.Core.Agent().GetDetachedOutput(pid) if err != nil { return nil, err } return []byte(output), nil }, WriteFn: func([]byte) error { return fmt.Errorf("read-only") }, BlockingReadFn: notBlocking, }, nil } switch name { case "new": return &FileConfig{ StatFn: func() (os.FileInfo, error) { return &SyntheticFileInfo{Name_: "new", Mode_: Perms[PathSessions].Files["new"]}, nil }, ReadFn: func() ([]byte, error) { return []byte("name=\ncwd=\nremote=\nbackend=\nmodel=\nagent=\nmaxTokens=\nmaxCompletionTokens=\ntemperature=\ntopP=\ntopK=\nminP=\ntopA=\nfrequencyPenalty=\npresencePenalty=\nrepetitionPenalty=\nreasoning=\nreasoningEffort=\nincludeReasoning=\nresponseFormat=\nstop=\nverbosity=\n"), nil }, WriteFn: func(data []byte) error { _, err := Create(rs, strings.Fields(strings.TrimSpace(string(data)))) return err }, BlockingReadFn: notBlocking, }, nil case "idx": return &FileConfig{ StatFn: func() (os.FileInfo, error) { return &SyntheticFileInfo{Name_: "idx", Mode_: Perms[PathSessions].Files["idx"]}, nil }, ReadFn: func() ([]byte, error) { return buildIndex(rs), nil }, WriteFn: func([]byte) error { return fmt.Errorf("idx: read-only") }, BlockingReadFn: notBlocking, }, nil } // Hierarchical paths: {id}/{file} or {id}/agent/{aid}/{file} parts = strings.SplitN(name, "/", 4) sessID := parts[0] rs.mu.RLock() sess := rs.sessions[sessID] rs.mu.RUnlock() if sess == nil { return nil, fmt.Errorf("%s: not found", name) } if len(parts) == 1 { return nil, fmt.Errorf("%s: is a directory", name) } if parts[1] == "agent" { if len(parts) < 4 { return nil, fmt.Errorf("%s: is a directory", name) } afs := openAgentTree(rs, sess) return afs.Open(parts[3]) } sfs := openSessionTree(rs, sess) return sfs.Open(parts[1]) } func rootDel(rs *rootState, name string) error { parts := strings.SplitN(name, "/", 3) sessID := parts[0] if len(parts) == 1 { rs.mu.RLock() _, ok := rs.sessions[sessID] rs.mu.RUnlock() if ok { Kill(rs, sessID) return nil } return fmt.Errorf("session not found: %s", sessID) } if len(parts) == 3 && parts[1] == "proc" { rs.mu.RLock() sess := rs.sessions[sessID] rs.mu.RUnlock() if sess == nil { return fmt.Errorf("session not found: %s", sessID) } pid, err := strconv.Atoi(parts[2]) if err != nil { return fmt.Errorf("invalid pid: %s", parts[2]) } if !sess.Core.Agent().DismissDetached(pid) { return fmt.Errorf("process %d not found or still running", pid) } return nil } return nil } func buildIndex(rs *rootState) []byte { var sb strings.Builder rs.mu.RLock() ids := make([]string, 0, len(rs.sessions)) for id := range rs.sessions { ids = append(ids, id) } sort.Strings(ids) for _, id := range ids { sess := rs.sessions[id] sess.mu.RLock() st := sess.Core.Agent().State() cwd := sess.Core.CWD() be := sess.Core.Agent().BackendName() model := sess.Core.Agent().ModelName() ag := sess.Core.Agent().Name() sess.mu.RUnlock() fmt.Fprintf(&sb, "%s\t%s\t%s\t%s\t%s\t%s\n", id, st, cwd, be, model, ag) } rs.mu.RUnlock() return []byte(sb.String()) }