package session import ( "context" "crypto/rand" "encoding/json" "fmt" "io" "os" "path/filepath" "strings" "sync" "time" "ollie/agent" "ollie/backend" olog "ollie/log" "ollie/paths" "ollie/toolsrv" ) // Config is the configuration for creating a session with an agent. type Config struct { Backend backend.Backend ModelName string Profile string // config profile name (e.g. "default") AgentsDir string SessionsDir string SessionID string AgentID string CWD string History *agent.History Runtime *agent.Runtime NewToolServer func() toolsrv.Runner NewBackend func(string) (backend.Backend, error) Log *olog.Logger MaxSteps int ReadPlanStep func() string ListHandlers map[string]func() []string PromptEnvExtra []string Remote string BaseLayers []string Output agent.EventHandler } // Session owns all session-level state: identity, agents, tool server lifecycle. type Session struct { // Identity id string // immutable UUID, set at creation name string // mutable friendly name, defaults to id prefix uname string // immutable user principal (numeric UID) // Context lifecycle ctx context.Context cancel context.CancelFunc // Agent state agents []*agent.Agent log *olog.Logger auditLog *olog.Logger sessionsDir string listHandlers map[string]func() []string remote string // Environment envMu sync.RWMutex env map[string]string plan []byte prevPrompt string // Tool server lifecycle proc *toolsrv.Process // toolsrv subprocess keeper *toolsrv.ProcessKeeper // resilient process manager toolsConn toolsrv.Runner // multiplexed RPC connection paused bool // true when paused // Tool restrictions disallowTools map[string]struct{} // Synchronization mu sync.RWMutex // Autosave saveMu sync.Mutex saveDirty bool saveTimer *time.Timer } // NewSessionID generates a UUIDv4 session identifier. func NewSessionID() string { b := make([]byte, 16) rand.Read(b) //nolint:errcheck b[6] = (b[6] & 0x0f) | 0x40 // version 4 b[8] = (b[8] & 0x3f) | 0x80 // variant 10xx return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) } // NextUncheckedStep returns the first unchecked step from plan bytes. func NextUncheckedStep(data []byte) string { for _, line := range strings.Split(string(data), "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "- [ ]") { return strings.TrimSpace(trimmed[5:]) } } return "" } var sweepTmpOnce sync.Once func ollieTmpDir() string { if p := os.Getenv("OLLIE_TMP_PATH"); p != "" { return p } return filepath.Join(os.TempDir(), "ollie") } func sweepStaleTmpDirs() { sweepTmpOnce.Do(func() { base := ollieTmpDir() os.RemoveAll(base) //nolint:errcheck os.MkdirAll(base, 0700) //nolint:errcheck }) } // New creates a session with an owned agent from the given configuration. func New(cfg Config) *Session { sweepStaleTmpDirs() if cfg.ModelName != "" { cfg.Backend.SetModel(cfg.ModelName) } if cfg.NewBackend == nil { cfg.NewBackend = backend.NewWithName } rt := cfg.Runtime if rt == nil { rt = &agent.Runtime{} } rt.Backend = cfg.Backend if cfg.MaxSteps > 0 { rt.MaxSteps = cfg.MaxSteps } if cfg.SessionID != "" { os.MkdirAll(filepath.Join(ollieTmpDir(), cfg.SessionID), 0700) //nolint:errcheck } log := cfg.Log if log == nil { log = olog.NewWriter("core", olog.LevelError+1, io.Discard, io.Discard) } auditLog := log.Sub("audit") name := cfg.SessionID if idx := strings.IndexByte(cfg.SessionID, '-'); idx > 0 { name = cfg.SessionID[:idx] } a := &Session{ id: cfg.SessionID, name: name, env: make(map[string]string), agents: make([]*agent.Agent, 0, 1), log: log, auditLog: auditLog, sessionsDir: cfg.SessionsDir, remote: cfg.Remote, listHandlers: cfg.ListHandlers, } ag := agent.NewAgent(agent.AgentCfg{ History: cfg.History, Runtime: rt, Profile: cfg.Profile, AgentsDir: cfg.AgentsDir, AgentID: cfg.AgentID, CWD: paths.ExpandHome(cfg.CWD), BaseLayers: cfg.BaseLayers, PromptEnvExtra: cfg.PromptEnvExtra, NewToolServer: cfg.NewToolServer, NewBackend: cfg.NewBackend, Output: cfg.Output, Log: log, AuditLog: auditLog, SessionID: cfg.SessionID, StartupMsgs: rt.Messages, ReadPlanStep: cfg.ReadPlanStep, Save: a.saveSession, Flush: a.flushSave, }) a.agents = append(a.agents, ag) ag.SetSessionEnv(a.id) return a } // FindAgent returns the agent matching the given name or ID, or nil. func (a *Session) FindAgent(nameOrID string) *agent.Agent { for _, ag := range a.agents { if ag.Name() == nameOrID || ag.ID() == nameOrID { return ag } } return nil } // Agents returns the full agent slice. func (a *Session) Agents() []*agent.Agent { return a.agents } // AgentAt returns the agent at the given index, or nil. func (a *Session) AgentAt(idx int) *agent.Agent { if idx < 0 || idx >= len(a.agents) { return nil } return a.agents[idx] } // AgentCount returns the number of agents. func (a *Session) AgentCount() int { return len(a.agents) } // SaveFuncs returns save and flush callbacks suitable for use as // SaveFuncs returns the save and flush callbacks for use in // agent.AgentCfg.Save and Flush when adding agents to this session. func (a *Session) SaveFuncs() (save func(), flush func()) { return a.saveSession, a.flushSave } // AddAgent appends an agent to the session. func (a *Session) AddAgent(ag *agent.Agent) { a.agents = append(a.agents, ag) ag.SetSessionEnv(a.id) } // RemoveAgent removes an agent by ID and closes it. // Returns false if the agent was not found. func (a *Session) RemoveAgent(id string) bool { idx := -1 for i, ag := range a.agents { if ag.ID() == id { idx = i break } } if idx < 0 { return false } a.agents[idx].Close() a.agents = append(a.agents[:idx], a.agents[idx+1:]...) return true } // Close releases resources for this session. func (a *Session) Close() { a.log.Debug("Close() session=%q", a.id) a.flushSave() for _, ag := range a.agents { ag.Close() } if a.id != "" { os.RemoveAll(filepath.Join(ollieTmpDir(), a.id)) //nolint:errcheck } } // SetEnv stores a session-scoped variable and propagates to all agents. func (a *Session) SetEnv(key, value string) { a.envMu.Lock() a.env[key] = value a.envMu.Unlock() for _, ag := range a.agents { ag.SetEnv(key, value) } } // CWD returns the current working directory for tool execution // (from the first agent). func (a *Session) CWD() string { if len(a.agents) > 0 { if c := a.agents[0].Cwd(); c != "" { return c } } wd, _ := os.Getwd() return wd } // Remote returns the remote target (e.g., SSH host) if any. func (a *Session) Remote() string { return a.remote } // SetRemote sets the remote target for this session. func (a *Session) SetRemote(remote string) { a.remote = remote } // SetCWD validates and sets the working directory on all agents. func (a *Session) SetCWD(dir string) error { dir = paths.ExpandHome(dir) if dir != "" { if _, err := os.Stat(dir); err != nil { return fmt.Errorf("cwd: %w", err) } } for _, ag := range a.agents { ag.SetCWD(dir) } return nil } // SetSessionID renames the session. func (a *Session) SetSessionID(newID string) error { oldID := a.id if oldID == newID { return nil } if a.sessionsDir != "" && oldID != "" { for _, suffix := range []string{".json", ".compaction.jsonl"} { oldPath := a.activeSessionPath(oldID, suffix) if _, err := os.Stat(oldPath); err == nil { if err := os.Rename(oldPath, a.activeSessionPath(newID, suffix)); err != nil { return fmt.Errorf("rename %s: %w", suffix, err) } } } } a.id = newID for _, ag := range a.agents { ag.RenamePreamble(oldID, newID) } oldTemp := filepath.Join(ollieTmpDir(), oldID) newTemp := filepath.Join(ollieTmpDir(), newID) if _, err := os.Stat(oldTemp); err == nil { os.Rename(oldTemp, newTemp) //nolint:errcheck } for _, ag := range a.agents { ag.SetSessionEnv(newID) } return nil } func (a *Session) activeSessionPath(id, suffix string) string { return filepath.Join(a.sessionsDir, "active", id+suffix) } func (a *Session) saveSession() { a.saveMu.Lock() a.saveDirty = true if a.saveTimer == nil { a.saveTimer = time.AfterFunc(2*time.Second, a.flushSave) } a.saveMu.Unlock() } func (a *Session) flushSave() { a.saveMu.Lock() dirty := a.saveDirty a.saveDirty = false if a.saveTimer != nil { a.saveTimer.Stop() a.saveTimer = nil } a.saveMu.Unlock() if !dirty || a.id == "" || a.sessionsDir == "" { return } // Use PersistSession for consistent format (writes Name field correctly) if err := PersistSession(a.Name()); err != nil { a.log.Error("session save: %v", err) } } // SaveSession writes the current session state. // The path argument is ignored; session is saved via PersistSession. func (a *Session) SaveSession(path string) error { return PersistSession(a.Name()) } // --- Identity accessors --- // ID returns the immutable session UUID. func (a *Session) ID() string { return a.id } // Name returns the mutable friendly name. func (a *Session) Name() string { a.mu.RLock() defer a.mu.RUnlock() return a.name } // SetName sets the friendly name. func (a *Session) SetName(name string) { a.mu.Lock() defer a.mu.Unlock() a.name = name } // Uname returns the immutable user principal. func (a *Session) Uname() string { return a.uname } // SetUname sets the user principal (should only be called during init). func (a *Session) SetUname(uname string) { a.mu.Lock() defer a.mu.Unlock() a.uname = uname } // --- Context lifecycle --- // Ctx returns the session context. func (a *Session) Ctx() context.Context { return a.ctx } // Cancel cancels the session context. func (a *Session) Cancel() { if a.cancel != nil { a.cancel() } } // SetContext sets the session context and cancel func. func (a *Session) SetContext(ctx context.Context, cancel context.CancelFunc) { a.ctx = ctx a.cancel = cancel } // --- Tool server lifecycle --- // Proc returns the tool server process. func (a *Session) Proc() *toolsrv.Process { a.mu.RLock() defer a.mu.RUnlock() return a.proc } // SetProc sets the tool server process. func (a *Session) SetProc(proc *toolsrv.Process) { a.mu.Lock() defer a.mu.Unlock() a.proc = proc } // Keeper returns the process keeper. func (a *Session) Keeper() *toolsrv.ProcessKeeper { a.mu.RLock() defer a.mu.RUnlock() return a.keeper } // SetKeeper sets the process keeper. func (a *Session) SetKeeper(keeper *toolsrv.ProcessKeeper) { a.mu.Lock() defer a.mu.Unlock() a.keeper = keeper } // ToolsConn returns the tool server connection. func (a *Session) ToolsConn() toolsrv.Runner { a.mu.RLock() defer a.mu.RUnlock() return a.toolsConn } // SetToolsConn sets the tool server connection. func (a *Session) SetToolsConn(conn toolsrv.Runner) { a.mu.Lock() defer a.mu.Unlock() a.toolsConn = conn } // IsPaused returns true if the session is paused. func (a *Session) IsPaused() bool { a.mu.RLock() defer a.mu.RUnlock() return a.paused } // SetPaused sets the paused state directly (used during restore). func (a *Session) SetPaused(paused bool) { a.mu.Lock() defer a.mu.Unlock() a.paused = paused } // IsConnected returns true if the tool server connection is alive. // Returns false if the session is paused. func (a *Session) IsConnected() bool { a.mu.RLock() paused := a.paused conn := a.toolsConn a.mu.RUnlock() if paused || conn == nil { return false } if pinger, ok := conn.(interface{ Ping() error }); ok { return pinger.Ping() == nil } return true } // Pause stops the tool server to save resources. // It cancels the session context (stopping all agent operations), // kills the tool server process, and marks the session as paused. func (a *Session) Pause() error { a.mu.Lock() defer a.mu.Unlock() if a.paused { return fmt.Errorf("session already paused") } // Cancel the session context to stop all agent operations. if a.cancel != nil { a.cancel() } // Close the tool server process. if a.keeper != nil { a.keeper.Close() } else if a.proc != nil { a.proc.Close() } // Close any existing connection. if a.toolsConn != nil { if c, ok := a.toolsConn.(interface{ Close() }); ok { c.Close() } a.toolsConn = nil } a.paused = true // Persist the paused state. go a.saveSession() return nil } // Resume restarts the tool server after a Pause. // It creates a fresh session context and respawns the tool server. // For sessions restored in paused state (no keeper), it sets up the tool server from scratch. func (a *Session) Resume() error { a.mu.Lock() defer a.mu.Unlock() if !a.paused { return fmt.Errorf("session not paused") } // Create a fresh context since the old one was cancelled on pause. ctx, cancel := context.WithCancel(serverCtx) a.ctx = ctx a.cancel = cancel // If we have a keeper, use it to respawn. Otherwise set up from scratch. if a.keeper != nil { a.keeper.SetContext(ctx) conn, err := a.keeper.Dial() if err != nil { return fmt.Errorf("resume failed: %w", err) } a.toolsConn = conn } else { // Session was restored paused without infra - set up now. cwd := a.CWD() infra, err := SetupToolServer(ToolServerConfig{ Ctx: ctx, CWD: cwd, RemoteTarget: a.remote, Yolo: pkgYolo, }) if err != nil { return fmt.Errorf("resume setup failed: %w", err) } a.proc = infra.Proc a.keeper = infra.Keeper a.toolsConn = infra.ToolsConn // Update agents with the new tool server connection. for _, ag := range a.agents { ag.SetToolServer(infra.NewToolServer, infra.ToolsConn) } } a.paused = false // Persist the resumed state. go a.saveSession() return nil } // --- Tool loading --- // SetDisallowTools sets the tool disallow list. func (a *Session) SetDisallowTools(disallow map[string]struct{}) { a.mu.Lock() defer a.mu.Unlock() a.disallowTools = disallow } // LoadTool loads a tool into the tool server. func (a *Session) LoadTool(name string, ag *agent.Agent) error { name = strings.TrimSpace(name) if name == "" { return nil } a.mu.RLock() _, blocked := a.disallowTools[name] a.mu.RUnlock() if blocked { return fmt.Errorf("tool %q is disallowed for this session", name) } var runner toolsrv.Runner if a.toolsConn != nil { runner = a.toolsConn } else if ag != nil { runner = ag.ToolServer() } return LoadToolOnRunner(runner, name) } // LoadToolOnRunner loads a tool on the given runner. func LoadToolOnRunner(runner toolsrv.Runner, name string) error { if runner == nil { return nil } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() args, _ := json.Marshal(map[string]string{"name": name}) if _, err := runner.CallTool(ctx, "tool_load", json.RawMessage(args)); err != nil { return fmt.Errorf("remote load: %w", err) } return nil } // --- Constructor for empty session --- // NewEmpty creates an empty session without an agent. func NewEmpty(id string, ctx context.Context, cancel context.CancelFunc) *Session { name := id if idx := strings.IndexByte(id, '-'); idx > 0 { name = id[:idx] } log := pkgSink.NewLogger("session") return &Session{ id: id, name: name, ctx: ctx, cancel: cancel, env: make(map[string]string), agents: make([]*agent.Agent, 0), log: log, auditLog: log.Sub("audit"), sessionsDir: pkgSessionsDir, } }