// Package session manages the lifecycle of agent sessions. // // A session owns one or more agents, a tool server connection, and persistence // state. The package-level registry (initialized via Init) tracks all active // sessions and provides lookup, creation, and teardown. // // Typical flow: Init → Create → Register → (use) → Kill or Shutdown. package session import ( "context" "crypto/rand" "fmt" "os" "sort" "strings" "sync" "time" "ollie/cmd/olliesrv/internal/agent" "ollie/cmd/olliesrv/internal/backend" olog "ollie/log" "ollie/util" ) // Package-level session registry with thread-safe access. var ( mu sync.RWMutex sessions = make(map[string]*Session) // Package config, set via Init. serverCtx context.Context pkgLog *olog.Logger pkgSink *olog.Sink pkgAgentsDir string pkgSessionsDir string pkgYolo bool ) // InitConfig configures the session package. type InitConfig struct { Ctx context.Context Log *olog.Logger Sink *olog.Sink AgentsDir string SessionsDir string Yolo bool } // Init initializes the session package with the given configuration. func Init(cfg InitConfig) { serverCtx = cfg.Ctx pkgLog = cfg.Log pkgSink = cfg.Sink pkgAgentsDir = cfg.AgentsDir pkgSessionsDir = cfg.SessionsDir pkgYolo = cfg.Yolo } // Sessions returns a snapshot of all sessions. func Sessions() map[string]*Session { mu.RLock() defer mu.RUnlock() result := make(map[string]*Session, len(sessions)) for k, v := range sessions { result[k] = v } return result } // Lookup returns the session by name or ID, or nil. func Lookup(nameOrID string) *Session { mu.RLock() defer mu.RUnlock() if s := sessions[nameOrID]; s != nil { return s } for _, sess := range sessions { if sess.ID == nameOrID { return sess } } return nil } // IsSubagent returns true if the given agent ID belongs to a sub-agent (depth > 0). func IsSubagent(agentID string) bool { mu.RLock() defer mu.RUnlock() for _, sess := range sessions { if ag := sess.FindAgent(agentID); ag != nil { return ag.Depth() > 0 } } return false } // Register adds a session to the registry. func Register(name string, sess *Session) { mu.Lock() sessions[name] = sess mu.Unlock() } // Kill terminates a session and removes it from the registry. func Kill(name string) error { mu.Lock() sess := sessions[name] if sess == nil { mu.Unlock() return fmt.Errorf("session not found: %s", name) } delete(sessions, name) mu.Unlock() RemovePersistedSession(sess.ID) var agentIDs []string for _, ag := range sess.Agents() { agentIDs = append(agentIDs, ag.ID()) } sess.Close() if sess.Cancel != nil { sess.Cancel() } if sess.Keeper != nil { sess.Keeper.Close() } else if sess.Proc != nil { sess.Proc.Close() } pkgLog.Info("killed session %s", name) for _, agentID := range agentIDs { PublishEvent("session."+sess.ID+".agent."+agentID+".kill", "") } PublishEvent("session."+sess.ID+".kill", "") return nil } // Rename renames a session. func Rename(oldName, newName string) error { mu.Lock() sess, ok := sessions[oldName] if !ok { mu.Unlock() return fmt.Errorf("session not found: %s", oldName) } if _, exists := sessions[newName]; exists { mu.Unlock() return fmt.Errorf("session already exists: %s", newName) } for _, ag := range sess.Agents() { if ag.IsRunning() { mu.Unlock() return fmt.Errorf("cannot rename while agent is running") } } sess.SetName(newName) sessions[newName] = sess delete(sessions, oldName) mu.Unlock() PersistSession(newName) pkgLog.Info("renamed session %s -> %s", oldName, newName) PublishEvent("session."+sess.ID+".rename", oldName+" "+newName) return nil } // Shutdown pauses all sessions (to allow later resume), waits for idle, then closes them. func Shutdown() { // First, pause all sessions to persist their state. PauseAll() waitIdle(100*time.Millisecond, 5*time.Second) mu.Lock() names := make([]string, 0, len(sessions)) for name := range sessions { names = append(names, name) } mu.Unlock() for _, name := range names { mu.Lock() sess := sessions[name] delete(sessions, name) mu.Unlock() if sess != nil { // Session is already paused, so Cancel/Keeper/Proc should be nil. // But close anything still open defensively. if sess.Cancel != nil { sess.Cancel() } sess.Close() if sess.Keeper != nil { sess.Keeper.Close() } else if sess.Proc != nil { sess.Proc.Close() } pkgLog.Info("shutdown session %s", name) } } } // PauseAll pauses every active session. Agents are interrupted first. func PauseAll() { InterruptAll() mu.RLock() var toProcess []*Session for _, sess := range sessions { if !sess.IsPaused() { toProcess = append(toProcess, sess) } } mu.RUnlock() for _, sess := range toProcess { if err := sess.Pause(); err != nil { pkgLog.Warn("pause session %s: %v", sess.ID, err) } } } // InterruptAll interrupts every active session. func InterruptAll() { mu.RLock() defer mu.RUnlock() for _, sess := range sessions { for _, ag := range sess.Agents() { ag.Interrupt(agent.ErrInterrupted) } } } func waitIdle(poll, timeout time.Duration) { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { allIdle := true mu.RLock() for _, sess := range sessions { for _, ag := range sess.Agents() { if ag.State() != "idle" { allIdle = false break } } if !allIdle { break } } mu.RUnlock() if allIdle { return } time.Sleep(poll) } pkgLog.Warn("shutdown: timed out waiting for idle") } // BuildIndex builds the session index content. // Format: session-id\tsession-name\tpaused\tconnected\tremote\tcwd func BuildIndex() []byte { var sb strings.Builder mu.RLock() defer mu.RUnlock() type entry struct { name string sess *Session paused bool } entries := make([]entry, 0, len(sessions)) for name, sess := range sessions { entries = append(entries, entry{name: name, sess: sess, paused: sess.IsPaused()}) } sort.Slice(entries, func(i, j int) bool { if entries[i].paused != entries[j].paused { return !entries[i].paused } return entries[i].name < entries[j].name }) for _, e := range entries { sess := e.sess sess.mu.RLock() connected := sess.toolsConn != nil && !sess.paused sess.mu.RUnlock() fmt.Fprintf(&sb, "%s\t%s\t%t\t%t\t%s\t%s\n", sess.ID, e.name, e.paused, connected, sess.Remote, sess.Cwd()) } return []byte(sb.String()) } // BuildAgentIndex builds the agent index for a session. // Format: session-id\tagent-id\tagent-name\tparent-id\tdepth\tstate func BuildAgentIndex(sess *Session) []byte { var sb strings.Builder for _, ag := range sess.Agents() { fmt.Fprintf(&sb, "%s\t%s\t%s\t%s\t%d\t%s\n", sess.ID, ag.ID(), ag.Name(), ag.ParentID(), ag.Depth(), ag.State()) } return []byte(sb.String()) } // CreateEmpty creates an empty session (no agent) and registers it. // CreateEmpty returns the session named `name`, creating it if it does not yet // exist (get-or-create). created reports whether a new session was made; when // false the returned session already existed and its configuration is left // untouched — creation never mutates an existing session. func CreateEmpty(name, remote string, yolo ...bool) (sess *Session, created bool, err error) { sessID := util.NewUUID() if name == "" { name = sessID if idx := strings.IndexByte(sessID, '-'); idx > 0 { name = sessID[:idx] } } // Early path: return the existing session without building a tool server. mu.RLock() existing := sessions[name] mu.RUnlock() if existing != nil { return existing, false, nil } ctx, cancel := context.WithCancel(serverCtx) sess = NewEmpty(sessID, ctx, cancel) sess.SetName(name) sess.Remote = remote sess.Yolo = len(yolo) > 0 && yolo[0] cwd, _ := os.Getwd() infra, err := SetupToolServer(ToolServerConfig{ Ctx: ctx, CWD: cwd, RemoteTarget: remote, SessionID: sessID, Yolo: sess.Yolo, }) if err != nil { cancel() return nil, false, fmt.Errorf("setup tool server: %w", err) } sess.Proc = infra.Proc sess.Keeper = infra.Keeper sess.SetToolsConn(infra.ToolsConn) // Start bypass approval loop sess.StartBypassLoop() // Atomic check-and-insert. If another caller won the race, discard our // freshly-built session and return theirs — still get-or-create. mu.Lock() if won := sessions[name]; won != nil { mu.Unlock() cancel() return won, false, nil } sessions[name] = sess mu.Unlock() pkgLog.Info("created empty session %s (id=%s)", name, sessID) PublishEvent("session."+sessID+".new", name) return sess, true, nil } // NextUname generates a unique agent name (12 hex chars). func NextUname() string { b := make([]byte, 6) rand.Read(b) return fmt.Sprintf("%06x%06x", b[:3], b[3:6]) } // --- Shared agent construction --- // AgentParams describes the parameters for building an agent within a session. type AgentParams struct { Profile string ID string // uname; if empty, generate one Name string // display name; optional Backend string // backend name; empty = config default Model string // model override; empty = config default Remote string // remote target override ParentID string // immutable ID of the agent that spawned this agent History *agent.History // nil = fresh agent } // buildAgent constructs a fully-wired agent within a session. // Uses the session's CWD. func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) { if p.ID == "" { p.ID = NextUname() } cwd := sess.Cwd() if cwd == "" { return nil, fmt.Errorf("session cwd is required") } cfg, err := agent.LoadConfig(pkgAgentsDir, p.Profile, nil) if err != nil { return nil, fmt.Errorf("load agent config %q: %w", p.Profile, err) } sessID := sess.ID // Set up tool server — reuse existing session infrastructure var reuseFrom *InfraConfig if sess.Proc != nil { reuseFrom = &InfraConfig{ Proc: sess.Proc, Keeper: sess.Keeper, ToolsConn: sess.ToolsConn(), } } remote := p.Remote if remote == "" { remote = sess.Remote } infra, err := SetupToolServer(ToolServerConfig{ Ctx: sess.Ctx, CWD: cwd, RemoteTarget: remote, SessionID: sessID, Yolo: sess.Yolo, ReuseFrom: reuseFrom, }) if err != nil { return nil, fmt.Errorf("setup tool server: %w", err) } // First agent in a session sets the shared infra if sess.Proc == nil { sess.Proc = infra.Proc sess.Keeper = infra.Keeper sess.SetToolsConn(infra.ToolsConn) } // Build prompt layers layers := BuildPromptLayers(cfg, cwd, sessID, p.ID, infra.Platform, infra.IsGitRepo, "") // Load tools LoadTools(cfg, infra.ToolsConn, sessID, p.ID, func(f string, a ...any) { pkgLog.Error("session %s agent %s: "+f, append([]any{sessID, p.ID}, a...)...) }) // Build runtime env := []string{"OLLIE_SESSION_ID=" + sessID, "OLLIE_UNAME=" + p.ID} rt := agent.BuildRuntime(cfg, infra.ToolsConn, cwd, env, layers.SystemPrompt, layers.EnvBlock) // Create backend beName := p.Backend if beName == "" && cfg != nil { beName = cfg.Backend } be, err := backend.NewWithName(beName) if err != nil { return nil, fmt.Errorf("create backend: %w", err) } rt.Backend = be if p.Model != "" { rt.Backend.SetModel(p.Model) } else if cfg != nil && cfg.Model != "" { rt.Backend.SetModel(cfg.Model) } ag := agent.NewAgent(agent.AgentParams{ History: p.History, Runtime: rt, Profile: p.Profile, AgentsDir: pkgAgentsDir, ID: p.ID, GetCwd: sess.Cwd, ParentID: p.ParentID, SystemPrompt: layers.SystemPrompt, EnvBlock: layers.EnvBlock, NewToolServer: infra.NewToolServer, NewBackend: backend.NewWithName, Log: pkgSink.NewLogger("agent"), SessionID: sessID, Save: sess.saveSession, Flush: sess.flushSave, }) if p.Name != "" { ag.SetName(p.Name) } return ag, nil } // CreateAgentWithParams creates a new agent within an existing session using // a pre-built AgentParams struct. func CreateAgentWithParams(sessName string, p AgentParams) (*agent.Agent, error) { sess := Lookup(sessName) if sess == nil { return nil, fmt.Errorf("session not found: %s", sessName) } if p.Profile == "" { p.Profile = "default" } if sess.Cwd() == "" { return nil, fmt.Errorf("session cwd is required") } ag, err := buildAgent(sess, p) if err != nil { return nil, err } if err := sess.AddAgent(ag); err != nil { ag.Close() return nil, err } pkgLog.Info("created agent %s in session %s (profile=%s)", ag.Name(), sessName, p.Profile) return ag, nil }