session: split session.go into focused files

Break the 1522-line monolith into logical modules:
- session.go (201) — Session struct + basic methods
- manager.go (535) — Manager lifecycle, peers, stat/open
- persist.go (347) — save/restore, shutdown
- create.go (297) — CreateSession, renameSession
- format.go (94) — FormatEvent, LoadAgentConfig
- complete.go (103) — code completion

No behavioral changes. All tests pass.
This commit is contained in:
Levi Neely 2026-07-29 20:03:27 +02:00
parent 3d2172d93a
commit 19939cc074
6 changed files with 1389 additions and 1334 deletions

103
session/complete.go Normal file
View File

@ -0,0 +1,103 @@
package session
import (
"context"
"fmt"
"hash/crc32"
"ollie/backend"
"ollie/paths"
"os"
"strings"
)
// --- Code Completion ---
// Complete performs a stateless one-shot code completion via backend.Generate.
func (s *Manager) Complete(cwd, filePath, prefix, suffix, extraContext string) (string, error) {
if cwd == "" {
cwd, _ = os.Getwd()
}
cwd = paths.ExpandHome(os.ExpandEnv(cwd))
model := os.Getenv("OLLIE_COMPLETE_MODEL")
backendName := os.Getenv("OLLIE_COMPLETE_BACKEND")
if model == "" || backendName == "" {
return "", fmt.Errorf("OLLIE_COMPLETE_MODEL and OLLIE_COMPLETE_BACKEND must be set")
}
const prefixMax = 12000
const suffixMax = 1000
if len(prefix) > prefixMax {
prefix = prefix[len(prefix)-prefixMax:]
}
if len(suffix) > suffixMax {
suffix = suffix[:suffixMax]
}
fileHint := ""
if filePath != "" {
fileHint = " in " + filePath
}
contextBlock := ""
if extraContext != "" {
contextBlock = "\n" + extraContext
}
prompt := fmt.Sprintf(`Implement the code at the cursor%s. The prefix ends at the point where new code is needed. Write the implementation — do not echo stubs, TODOs, or placeholder returns from the prefix. Output ONLY raw code. No reasoning, no shell commands, no explanations, no markdown fences, no backticks, no preamble. Your entire response must be valid code that can be inserted directly into the file.
%s
<prefix>
%s
</prefix>
<suffix>
%s
</suffix>`, fileHint, contextBlock, prefix, suffix)
result, err := backend.Generate(context.Background(), backend.GenerateRequest{
Prompt: prompt,
Backend: backendName,
Model: model,
})
if err != nil {
return "", err
}
result = stripCompletionNoise(result)
result = stripPrefixEcho(prefix, result)
return result, nil
}
func stripCompletionNoise(s string) string {
var lines []string
for _, line := range strings.Split(s, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") {
continue
}
if trimmed == "<prefix>" || trimmed == "</prefix>" ||
trimmed == "<suffix>" || trimmed == "</suffix>" {
continue
}
if strings.HasPrefix(trimmed, ":: ") {
continue
}
lines = append(lines, line)
}
return strings.Join(lines, "\n")
}
func stripPrefixEcho(prefix, result string) string {
tailMax := 200
if len(prefix) < tailMax {
tailMax = len(prefix)
}
for i := tailMax; i > 0; i-- {
tail := prefix[len(prefix)-i:]
if strings.HasPrefix(result, tail) {
return result[len(tail):]
}
}
return result
}
func crc32Str(s string) uint32 {
return crc32.ChecksumIEEE([]byte(s))
}

297
session/create.go Normal file
View File

@ -0,0 +1,297 @@
package session
import (
agent "ollie/session"
"context"
"fmt"
"ollie/backend"
"ollie/execute"
"ollie/paths"
"ollie/tools"
"olliesrv/fs"
"olliesrv/prompts"
"os"
"strings"
)
func (s *Manager) CreateSession(args []string) (string, error) {
name := ""
backendOverride := ""
modelOverride := ""
agentName := ""
cwd := ""
remoteTarget := ""
systemPromptOverride := ""
for _, arg := range args {
k, v, ok := strings.Cut(arg, "=")
if !ok {
return "", fmt.Errorf("invalid option %q (expected key=value)", arg)
}
if v == "" {
continue
}
switch k {
case "name":
name = v
case "backend":
backendOverride = v
case "model":
modelOverride = v
case "agent":
agentName = v
case "cwd":
cwd = v
case "remote":
remoteTarget = v
case "system_prompt":
systemPromptOverride = v
default:
return "", fmt.Errorf("unknown option %q (valid: name, backend, model, agent, cwd, remote, system_prompt)", k)
}
}
if agentName == "" {
if v := os.Getenv("OLLIE_DEFAULT_AGENT"); v != "" {
agentName = v
} else {
agentName = "default"
}
}
cwd = paths.ExpandHome(os.ExpandEnv(cwd))
if cwd == "" {
return "", fmt.Errorf("cwd is required (e.g. new cwd=/path/to/project)")
}
sessID := name
if sessID == "" {
sessID = agent.NewSessionID()
}
s.mu.RLock()
_, exists := s.sessions[sessID]
s.mu.RUnlock()
if exists {
return "", fmt.Errorf("session already exists: %s", sessID)
}
var core *agent.Session
var sessPtr *Session
uname := s.nextUname()
if s.cfg.NewCore != nil {
var err error
core, err = s.cfg.NewCore(sessID, agentName, cwd)
if err != nil {
return "", err
}
} else {
cfg := LoadAgentConfig(s.cfg.AgentsDir, agentName, nil)
if cfg != nil {
if backendOverride == "" && cfg.Backend != "" {
backendOverride = cfg.Backend
}
if modelOverride == "" && cfg.Model != "" {
modelOverride = cfg.Model
}
}
be, err := backend.NewWithName(backendOverride)
if err != nil {
return "", fmt.Errorf("backend: %w", err)
}
if modelOverride == "" {
modelOverride = os.Getenv("OLLIE_MODEL")
}
if modelOverride != "" {
be.SetModel(modelOverride)
}
if err := s.cfg.MkdirAll(s.cfg.SessionsDir, fs.PermMkdirPrivate); err != nil {
return "", fmt.Errorf("sessions dir: %w", err)
}
var execOpts []execute.Option
if !s.cfg.NoMount {
}
if s.cfg.Strict {
execOpts = append(execOpts, execute.WithStrict())
}
if s.cfg.Yolo {
execOpts = append(execOpts, execute.WithYolo())
}
if cfg != nil && len(cfg.AllowTools) > 0 {
execOpts = append(execOpts, execute.WithAllowTools(cfg.AllowTools))
}
if s.cfg.ToolRegistry != nil {
execOpts = append(execOpts, execute.WithToolRegistry(s.cfg.ToolRegistry, sessID))
}
if s.cfg.SkillsRegistry != nil {
execOpts = append(execOpts, execute.WithSkillsRegistry(s.cfg.SkillsRegistry))
}
var newDisp func() tools.Dispatcher
var remoteEnv []string // extra env vars from remote host info
if remoteTarget != "" {
// Dial eagerly so we get HostInfo for prompt resolution.
// FIXME: Eager dial means session creation blocks on SSH connect.
// Eventually make this async or move host info into the prompt
// scripts themselves (running remotely via the RPC).
rsrv, dialErr := execute.RemoteDial(context.Background(), execute.RemoteConfig{
SSHTarget: remoteTarget,
CWD: cwd,
})
if dialErr != nil {
return "", fmt.Errorf("remote dial: %w", dialErr)
}
newDisp = tools.NewDispatcherFunc(map[string]func() tools.Server{
"execute": func() tools.Server { return rsrv },
})
remoteEnv = []string{
"PRIME_CWD=" + cwd,
"PRIME_PLATFORM=" + rsrv.Info.Platform,
"PRIME_IS_GIT_REPO=" + fmt.Sprintf("%v", rsrv.Info.IsGitRepo),
}
} else {
newDisp = tools.NewDispatcherFunc(map[string]func() tools.Server{
"execute": execute.Decl(cwd, execOpts...),
})
}
var promptEnv []string
if len(remoteEnv) > 0 {
promptEnv = remoteEnv
} else {
promptEnv = agent.PromptEnv(cwd)
}
env := []string{"OLLIE_SESSION_ID=" + sessID, "OLLIE_UNAME=" + uname}
env = append(env, promptEnv...)
// Compose base layers: system prompt + operational model + environment.
spOverride := systemPromptOverride
if spOverride == "" && cfg != nil {
spOverride = cfg.SystemPrompt
}
sysPrompt := prompts.ResolveSystemPrompt(spOverride)
envMap := make(map[string]string)
for _, e := range env {
if k, v, ok := strings.Cut(e, "="); ok {
envMap[k] = v
}
}
opModel := prompts.OperationalModel(s.cfg.Enable9P, s.cfg.EnableDBus, envMap)
// Determine environment values for the environment layer.
platform := "linux"
isGitRepo := false
for _, e := range promptEnv {
if k, v, ok := strings.Cut(e, "="); ok {
switch k {
case "PRIME_PLATFORM":
platform = v
case "PRIME_IS_GIT_REPO":
isGitRepo = v == "true"
}
}
}
envBlock := prompts.Environment(cwd, platform, isGitRepo, "")
disp := newDisp()
rt := agent.BuildRuntime(cfg, disp, cwd, env, sysPrompt, opModel, envBlock)
// sessPtr is set after NewSession; the ReadPlanStep closure captures it.
core = agent.New(agent.Config{
Backend: be,
AgentName: agentName,
AgentsDir: s.cfg.AgentsDir,
SessionsDir: s.cfg.SessionsDir,
SessionID: sessID,
Uname: uname,
CWD: cwd,
Remote: remoteTarget,
Runtime: rt,
NewDispatcher: newDisp,
PromptEnvExtra: promptEnv,
BaseLayers: []string{sysPrompt, opModel, envBlock},
Log: s.cfg.Sink.NewLogger("core"),
ReadPlanStep: func() string {
if sessPtr == nil {
return ""
}
sessPtr.mu.RLock()
data := make([]byte, len(sessPtr.plan))
copy(data, sessPtr.plan)
sessPtr.mu.RUnlock()
return agent.NextUncheckedStep(data)
},
})
}
ctx, cancel := context.WithCancel(context.Background())
sess := NewSession(sessID, core, ctx, cancel)
sessPtr = sess
sess.remote = remoteTarget
// For remote sessions, the mount isn't managed by a local execute.Server
// (which doesn't exist). Start it directly so $OLLIE is available for
s.mu.Lock()
sess.uname = uname
s.sessions[sessID] = sess
s.mu.Unlock()
// HACK: Load the agent now that the session mount is active.
core.Submit(ctx, "/agent "+agentName)
s.cfg.Log.Info("new session %s (backend=%s model=%s agent=%s)",
sessID, core.BackendName(), core.ModelName(), core.AgentName())
if s.cfg.OnSessionCreated != nil {
s.cfg.OnSessionCreated(sessID, sess)
}
return sessID, nil
}
// RenameSession renames a session (exported for D-Bus adapter).
func (s *Manager) RenameSession(old, new string) error {
return s.renameSession(old, new)
}
func (s *Manager) renameSession(old, new string) error {
// Session rename: {oldID} -> {newID}
oldID := old
newID := new
s.mu.Lock()
sess, ok := s.sessions[oldID]
if !ok {
s.mu.Unlock()
return fmt.Errorf("session not found: %s", oldID)
}
if _, exists := s.sessions[newID]; exists {
s.mu.Unlock()
return fmt.Errorf("session already exists: %s", newID)
}
if sess.Core.IsRunning() {
s.mu.Unlock()
return fmt.Errorf("cannot rename while agent is running")
}
if err := sess.Core.SetSessionID(newID); err != nil {
s.mu.Unlock()
return err
}
sess.id = newID
s.sessions[newID] = sess
delete(s.sessions, oldID)
s.mu.Unlock()
sess.AppendLog([]byte(fmt.Sprintf(":: session renamed: %s -> %s\n", oldID, newID)))
s.cfg.Log.Info("renamed session %s -> %s", oldID, newID)
if s.cfg.OnSessionRenamed != nil {
s.cfg.OnSessionRenamed(oldID, newID)
}
return nil
}
// LoadAgentConfig resolves and loads the config for a named agent.
// Returns nil if the config file does not exist; BuildRuntime handles nil configs.

94
session/format.go Normal file
View File

@ -0,0 +1,94 @@
package session
import (
agent "ollie/session"
"ollie/backend"
"os"
"strings"
)
func LoadAgentConfig(agentsDir, name string, open func(string) (*os.File, error)) *agent.AgentConfig {
if open == nil {
open = os.Open
}
path := agent.AgentConfigPath(agentsDir, name)
f, err := open(path)
if err != nil {
return nil
}
defer f.Close()
cfg, _ := agent.Load(f)
return cfg
}
// FormatEvent converts an agent Event to bytes for appending to a chat log.
// Streaming roles (assistant, reasoning) return only the content chunk;
// the caller (startEventLog) is responsible for writing the [role] header.
func FormatEvent(ev agent.Event) []byte {
switch ev.Role {
case "user":
return []byte("[user]\n" + ev.Content + "\n")
case "assistant":
return []byte(ev.Content)
case "reasoning":
return []byte(ev.Content)
case "call":
args := squashWhitespace(ev.Content)
return []byte("[call:" + ev.Name + "]\n" + args + "\n")
case "tool":
return []byte("[tool:" + ev.Name + "]\n" + strings.TrimRight(ev.Content, "\n") + "\n")
case "error":
return []byte("[error]\n" + ev.Content + "\n")
case "info":
return []byte("[info]\n" + ev.Content)
case "retry":
return []byte("[retry]\n" + ev.Content + "s\n")
case "stalled":
return []byte("[stalled]\n")
case "maxsteps":
return []byte("[maxsteps]\n" + ev.Content + "\n")
default:
return nil
}
}
func squashWhitespace(s string) string {
return strings.Join(strings.Fields(s), " ")
}
// replayMessagesToLog renders the tail of a persisted message list into the
// session's chat log so that restored sessions show recent history.
func replayMessagesToLog(sess *Session, messages []backend.Message) {
const maxReplay = 20
start := len(messages) - maxReplay
if start < 0 {
start = 0
}
// Skip system messages at the start of the replay window.
for start < len(messages) && messages[start].Role == "system" {
start++
}
for _, m := range messages[start:] {
switch m.Role {
case "system":
// skip
case "user":
sess.AppendLog([]byte("[user]\n" + m.Content + "\n"))
case "assistant":
header := "[assistant]"
if m.ID != "" {
header = "[assistant:" + m.ID + "]"
}
sess.AppendLog([]byte(header + "\n"))
if m.Content != "" {
sess.AppendLog([]byte(m.Content + "\n"))
}
for _, tc := range m.ToolCalls {
args := squashWhitespace(string(tc.Arguments))
sess.AppendLog([]byte("[call:" + tc.Name + "]\n" + args + "\n"))
}
case "tool":
sess.AppendLog([]byte("[tool]\n" + strings.TrimRight(m.Content, "\n") + "\n"))
}
}
}

535
session/manager.go Normal file
View File

@ -0,0 +1,535 @@
package session
import (
olog "ollie/log"
"context"
"fmt"
"ollie/paths"
agent "ollie/session"
"ollie/skills"
"ollie/tools"
"olliesrv/fs"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
)
var sessionStoreOrder = []string{"new", "idx", "ls", "kill", "sh", "b", "bfg", "bbg", "cleanup"}
// 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 := fs.Perms[fs.PathSessions].Files[name]
return m, ok
}
// ManagerConfig holds the dependencies for a Manager.
type ManagerConfig struct {
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) (*agent.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()
// ResetElevation resets the per-turn elevation rate limiter for a session.
ResetElevation func(sessionID string)
// ToolRegistry is the shared tool registry for lazy tool promotion.
ToolRegistry *tools.Registry
// SkillsRegistry is the shared skills registry for skill loading.
SkillsRegistry *skills.Registry
// OnSessionCreated is called after a new session is added to the manager.
// Receives the session ID and the Session pointer.
OnSessionCreated func(id string, sess *Session)
// OnSessionKilled is called after a session is removed from the manager.
OnSessionKilled func(id string)
// OnSessionRenamed is called after a session is renamed.
OnSessionRenamed func(oldID, newID string)
}
// Manager manages session lifecycle and exposes sessions as a Tree.
type Manager struct {
tree *fs.Tree
cfg ManagerConfig
mu sync.RWMutex
sessions map[string]*Session
nextUID atomic.Uint32 // incrementing principal counter
}
// Tree returns the Tree view of the session namespace.
func (s *Manager) Tree() *fs.Tree { return s.tree }
// nextUname generates the next uname atomically.
func (s *Manager) nextUname() string {
return fmt.Sprintf("%d", s.nextUID.Add(1))
}
func NewManager(cfg ManagerConfig) *Manager {
if cfg.ReadFile == nil {
cfg.ReadFile = os.ReadFile
}
if cfg.MkdirAll == nil {
cfg.MkdirAll = os.MkdirAll
}
ss := &Manager{
cfg: cfg,
sessions: make(map[string]*Session),
}
ss.nextUID.Store(9999)
ss.restoreAllSessions()
ss.tree = fs.NewTree(nil, 0,
fs.WithStat(func(_ []string, name string) (os.FileInfo, error) { return ss.stat(name) }),
fs.WithOpener(func(_ []string, name string) (fs.File, error) { return ss.openEntry(name) }),
fs.WithLister(func(_ []string) ([]os.DirEntry, error) { return ss.list() }),
fs.WithReaddir(func(_ []string, name string) ([]os.DirEntry, error) { return ss.Readdir(name) }),
fs.WithDeleter(func(_ []string, name string) error { return ss.del(name) }),
fs.WithCreator(func(_ []string, name string, _ os.FileMode) error { return ss.create(name) }),
fs.WithRenamer(func(_ []string, old, new string) error { return ss.renameSession(old, new) }),
)
return ss
}
// AddSession inserts a pre-built session into the fs.
func (s *Manager) AddSession(sess *Session) {
s.mu.Lock()
s.sessions[sess.RunnableID()] = sess
s.mu.Unlock()
}
// List returns all root-level entries (fixed files + session directories).
func (s *Manager) List() ([]os.DirEntry, error) {
return s.list()
}
func (s *Manager) list() ([]os.DirEntry, error) {
entries := make([]os.DirEntry, 0, len(sessionStoreOrder))
for _, name := range sessionStoreOrder {
entries = append(entries, fs.FileEntry(name, fs.Perms[fs.PathSessions].Files[name]))
}
s.mu.RLock()
for id := range s.sessions {
entries = append(entries, fs.DirEntry(id, fs.Perms[fs.PathSessionDir].DirMode))
}
s.mu.RUnlock()
return entries, nil
}
// Readdir lists entries in a subdirectory (e.g. "{id}", "{id}/peer").
func (s *Manager) Readdir(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 + peer/
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
}
// Append peer/ and proc/ directories
entries = append(entries, fs.DirEntry("peer", 0755))
entries = append(entries, fs.DirEntry("proc", 0755))
return entries, nil
}
// {id}/peer — list peer session IDs
if len(parts) == 2 && parts[1] == "peer" {
peers, _ := s.PeerList(sessID)
entries := make([]os.DirEntry, len(peers))
for i, p := range peers {
entries[i] = fs.FileEntry(p, 0666)
}
return entries, nil
}
// {id}/proc — list detached process PIDs
if len(parts) == 2 && parts[1] == "proc" {
procs := sess.Core.ListDetached()
entries := make([]os.DirEntry, len(procs))
for i, p := range procs {
entries[i] = fs.FileEntry(fmt.Sprintf("%d", p.PID), 0666)
}
return entries, nil
}
return nil, fmt.Errorf("%s: not a directory", name)
}
// --- Peer Management ---
// PeerAdd creates a bidirectional peer link between two sessions.
func (s *Manager) PeerAdd(sessID, peerID string) error {
s.mu.RLock()
sess, ok := s.sessions[sessID]
peer, peerOk := s.sessions[peerID]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("session not found: %s", sessID)
}
if !peerOk {
return fmt.Errorf("peer session not found: %s", peerID)
}
if sessID == peerID {
return fmt.Errorf("cannot peer a session with itself")
}
sess.mu.Lock()
if sess.peers == nil {
sess.peers = make(map[string]bool)
}
sess.peers[peerID] = true
sess.mu.Unlock()
peer.mu.Lock()
if peer.peers == nil {
peer.peers = make(map[string]bool)
}
peer.peers[sessID] = true
peer.mu.Unlock()
return nil
}
// PeerRemove removes a bidirectional peer link between two sessions.
func (s *Manager) PeerRemove(sessID, peerID string) error {
s.mu.RLock()
sess, ok := s.sessions[sessID]
peer, peerOk := s.sessions[peerID]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("session not found: %s", sessID)
}
sess.mu.Lock()
delete(sess.peers, peerID)
sess.mu.Unlock()
if peerOk {
peer.mu.Lock()
delete(peer.peers, sessID)
peer.mu.Unlock()
}
return nil
}
// PeerList returns the peer session IDs for a session.
func (s *Manager) PeerList(sessID string) ([]string, error) {
s.mu.RLock()
sess, ok := s.sessions[sessID]
s.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("session not found: %s", sessID)
}
sess.mu.RLock()
result := make([]string, 0, len(sess.peers))
for id := range sess.peers {
result = append(result, id)
}
sess.mu.RUnlock()
return result, nil
}
// PeerSubmit sends a prompt to a peer session.
func (s *Manager) PeerSubmit(sessID, peerID, prompt string) error {
s.mu.RLock()
sess, ok := s.sessions[sessID]
peer, peerOk := s.sessions[peerID]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("session not found: %s", sessID)
}
if !peerOk {
return fmt.Errorf("peer session not found: %s", peerID)
}
sess.mu.RLock()
isPeer := sess.peers[peerID]
sess.mu.RUnlock()
if !isPeer {
return fmt.Errorf("%s is not a peer of %s", peerID, sessID)
}
go peer.Core.Submit(peer.Ctx, prompt)
return nil
}
func (s *Manager) stat(name string) (os.FileInfo, error) {
// Top-level fixed files (new, idx, sh, etc.)
if mode, ok := fs.Perms[fs.PathSessions].Files[name]; ok {
return &fs.SyntheticFileInfo{Name_: name, Mode_: mode}, nil
}
parts := strings.SplitN(name, "/", 3)
sessID := parts[0]
s.mu.RLock()
sess, ok := s.sessions[sessID]
s.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("%s: not found", name)
}
// Session directory: {id}
if len(parts) == 1 {
return &fs.SyntheticFileInfo{Name_: sessID, Mode_: fs.Perms[fs.PathSessionDir].DirMode, IsDir_: true}, nil
}
// Peer directory: {id}/peer
if parts[1] == "peer" {
if len(parts) == 2 {
return &fs.SyntheticFileInfo{Name_: "peer", Mode_: 0755, IsDir_: true}, nil
}
// Peer file: {id}/peer/{peer-id}
peerID := parts[2]
sess.mu.RLock()
isPeer := sess.peers[peerID]
sess.mu.RUnlock()
if !isPeer {
return nil, fmt.Errorf("%s: not found", name)
}
return &fs.SyntheticFileInfo{Name_: peerID, Mode_: 0666}, nil
}
// Proc directory: {id}/proc
if parts[1] == "proc" {
if len(parts) == 2 {
return &fs.SyntheticFileInfo{Name_: "proc", Mode_: 0755, IsDir_: true}, nil
}
// Proc file: {id}/proc/{pid} — read-only output
return &fs.SyntheticFileInfo{Name_: parts[2], Mode_: 0444}, nil
}
// Session file: {id}/{file}
sfs, err := s.openStore(sess)
if err != nil {
return nil, err
}
return sfs.Stat(parts[1])
}
func (s *Manager) openEntry(name string) (fs.File, error) {
notBlocking := func(context.Context, string) ([]byte, string, error) {
return nil, "", fmt.Errorf("blocking read not supported")
}
// Peer file: {id}/peer/{peer-id} — write submits prompt to peer
parts := strings.SplitN(name, "/", 3)
// Proc file: {id}/proc/{pid} — read returns ring buffer output
if len(parts) == 3 && parts[1] == "proc" {
sessID := parts[0]
sess := s.Session(sessID)
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 &fs.FileConfig{
StatFn: func() (os.FileInfo, error) {
return &fs.SyntheticFileInfo{Name_: pidStr, Mode_: 0444}, nil
},
ReadFn: func() ([]byte, error) {
output, err := sess.Core.GetDetachedOutput(pid)
if err != nil {
return nil, err
}
return []byte(output), nil
},
WriteFn: func([]byte) error { return fmt.Errorf("read-only") },
BlockingReadFn: notBlocking,
}, nil
}
if len(parts) == 3 && parts[1] == "peer" {
sessID := parts[0]
peerID := parts[2]
return &fs.FileConfig{
StatFn: func() (os.FileInfo, error) {
return &fs.SyntheticFileInfo{Name_: peerID, Mode_: 0666}, nil
},
ReadFn: func() ([]byte, error) {
return nil, nil // empty read
},
WriteFn: func(data []byte) error {
prompt := strings.TrimSpace(string(data))
if prompt == "" {
return nil
}
return s.PeerSubmit(sessID, peerID, prompt)
},
BlockingReadFn: notBlocking,
}, nil
}
// Top-level fixed files.
switch name {
case "new":
return &fs.FileConfig{
StatFn: func() (os.FileInfo, error) {
return &fs.SyntheticFileInfo{Name_: "new", Mode_: fs.Perms[fs.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 := s.CreateSession(strings.Fields(strings.TrimSpace(string(data))))
return err
},
BlockingReadFn: notBlocking,
}, nil
case "idx":
return &fs.FileConfig{
StatFn: func() (os.FileInfo, error) {
return &fs.SyntheticFileInfo{Name_: "idx", Mode_: fs.Perms[fs.PathSessions].Files["idx"]}, nil
},
ReadFn: func() ([]byte, error) { return s.index(), nil },
WriteFn: func([]byte) error { return fmt.Errorf("idx: read-only") },
BlockingReadFn: notBlocking,
}, nil
}
if _, ok := fs.Perms[fs.PathSessions].Files[name]; ok {
return &fs.FileConfig{
StatFn: func() (os.FileInfo, error) {
return &fs.SyntheticFileInfo{Name_: name, Mode_: fs.Perms[fs.PathSessions].Files[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)
}
// Session file: {id}/{file}
sfs, err := s.openStore(sess)
if err != nil {
return nil, err
}
return sfs.Open(parts[1])
}
func (s *Manager) create(name string) error {
// Peer creation: {id}/peer/{peer-id}
parts := strings.SplitN(name, "/", 3)
if len(parts) == 3 && parts[1] == "peer" {
return s.PeerAdd(parts[0], parts[2])
}
return fmt.Errorf("create not supported: %s", name)
}
func (s *Manager) del(name string) error {
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)
}
// Peer removal: {id}/peer/{peer-id}
if len(parts) == 3 && parts[1] == "peer" {
return s.PeerRemove(sessID, parts[2])
}
// Proc dismiss: {id}/proc/{pid}
if len(parts) == 3 && parts[1] == "proc" {
sess := s.Session(sessID)
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.DismissDetached(pid) {
return fmt.Errorf("process %d not found or still running", pid)
}
return nil
}
// Session files are synthetic; allow rm -r to continue.
return nil
}
// Session returns the session for the given ID, or nil.
func (s *Manager) Session(id string) *Session {
s.mu.RLock()
defer s.mu.RUnlock()
return s.sessions[id]
}
// SessionByUname returns the session with the given uname (principal), or nil.
func (s *Manager) SessionByUname(uname string) *Session {
s.mu.RLock()
defer s.mu.RUnlock()
for _, sess := range s.sessions {
if sess.uname == uname {
return sess
}
}
return nil
}
// OpenStore returns a Tree for the given session ID.
func (s *Manager) OpenStore(id string) (*fs.Tree, error) {
if sess := s.Session(id); sess != nil {
return s.openStore(sess)
}
return nil, fmt.Errorf("session not found: %s", id)
}
func (s *Manager) openStore(sess *Session) (*fs.Tree, error) {
var resetElev func()
if s.cfg.ResetElevation != nil {
id := sess.id
resetElev = func() { s.cfg.ResetElevation(id) }
}
return NewSessionTree(
sess,
s.cfg.Log,
func() { s.KillSession(sess.id) },
func(newID string) error { return s.renameSession(sess.id, newID) },
nil, // no transcript saving
s.cfg.InvalidateModels,
resetElev,
s.cfg.ToolRegistry,
), nil
}
// InterruptAll interrupts every active session.
func (s *Manager) InterruptAll() {
s.mu.RLock()
defer s.mu.RUnlock()
for _, sess := range s.sessions {
sess.Core.Interrupt(agent.ErrInterrupted)
}
}
// Shutdown kills all active sessions.

347
session/persist.go Normal file
View File

@ -0,0 +1,347 @@
package session
import (
agent "ollie/session"
"context"
"fmt"
"ollie/backend"
"ollie/execute"
"ollie/tools"
"olliesrv/prompts"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// --- Session Persistence ---
func (s *Manager) activeSessionsDir() string {
return filepath.Join(s.cfg.SessionsDir, "active")
}
func (s *Manager) persistSession(id string) {
s.mu.RLock()
sess, ok := s.sessions[id]
s.mu.RUnlock()
if !ok {
return
}
dir := s.activeSessionsDir()
os.MkdirAll(dir, 0700)
path := filepath.Join(dir, id+".json")
if err := sess.Core.SaveSession(path); err != nil {
s.cfg.Log.Error("persist session %s: %v", id, err)
}
}
func (s *Manager) removePersistedSession(id string) {
path := filepath.Join(s.activeSessionsDir(), id+".json")
os.Remove(path)
}
func (s *Manager) saveAllSessions() {
s.mu.RLock()
ids := make([]string, 0, len(s.sessions))
for id := range s.sessions {
ids = append(ids, id)
}
s.mu.RUnlock()
for _, id := range ids {
s.persistSession(id)
}
}
func (s *Manager) restoreAllSessions() {
dir := s.activeSessionsDir()
entries, err := os.ReadDir(dir)
if err != nil {
return
}
// Load all persisted session JSONs (fast, sequential disk reads)
type loadedSession struct {
ps *agent.PersistedSession
name string
}
var loaded []loadedSession
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".json") {
continue
}
path := filepath.Join(dir, e.Name())
ps, err := agent.LoadPersistedSession(path)
if err != nil {
s.cfg.Log.Error("restore session %s: %v", e.Name(), err)
continue
}
loaded = append(loaded, loadedSession{ps: ps, name: e.Name()})
}
if len(loaded) == 0 {
return
}
// Restore sessions in parallel
var wg sync.WaitGroup
for _, ls := range loaded {
wg.Add(1)
go func(ls loadedSession) {
defer wg.Done()
if err := s.restoreSession(ls.ps); err != nil {
s.cfg.Log.Error("restore session %s: %v", ls.ps.ID, err)
}
}(ls)
}
wg.Wait()
}
func (s *Manager) restoreSession(ps *agent.PersistedSession) error {
cwd := ps.CWD
if cwd == "" {
cwd, _ = os.Getwd()
}
agentName := ps.Agent
if agentName == "" {
agentName = "default"
}
sessID := ps.ID
cfg := LoadAgentConfig(s.cfg.AgentsDir, agentName, nil)
backendName := ps.Backend
if backendName == "" && cfg != nil && cfg.Backend != "" {
backendName = cfg.Backend
}
be, err := backend.NewWithName(backendName)
if err != nil {
return fmt.Errorf("backend: %w", err)
}
modelName := ps.Model
if modelName == "" && cfg != nil && cfg.Model != "" {
modelName = cfg.Model
}
if modelName == "" {
modelName = os.Getenv("OLLIE_MODEL")
}
if modelName != "" {
be.SetModel(modelName)
}
uname := s.nextUname()
var newDisp func() tools.Dispatcher
var promptEnv []string
remoteTarget := ps.Remote
if remoteTarget != "" {
rsrv, dialErr := execute.RemoteDial(context.Background(), execute.RemoteConfig{
SSHTarget: remoteTarget,
CWD: cwd,
})
if dialErr != nil {
return fmt.Errorf("remote dial: %w", dialErr)
}
newDisp = tools.NewDispatcherFunc(map[string]func() tools.Server{
"execute": func() tools.Server { return rsrv },
})
promptEnv = []string{
"PRIME_CWD=" + cwd,
"PRIME_PLATFORM=" + rsrv.Info.Platform,
"PRIME_IS_GIT_REPO=" + fmt.Sprintf("%v", rsrv.Info.IsGitRepo),
}
} else {
var execOpts []execute.Option
if !s.cfg.NoMount {
}
if s.cfg.Strict {
execOpts = append(execOpts, execute.WithStrict())
}
if s.cfg.Yolo {
execOpts = append(execOpts, execute.WithYolo())
}
if s.cfg.ToolRegistry != nil {
execOpts = append(execOpts, execute.WithToolRegistry(s.cfg.ToolRegistry, sessID))
}
if s.cfg.SkillsRegistry != nil {
execOpts = append(execOpts, execute.WithSkillsRegistry(s.cfg.SkillsRegistry))
}
newDisp = tools.NewDispatcherFunc(map[string]func() tools.Server{
"execute": execute.Decl(cwd, execOpts...),
})
promptEnv = agent.PromptEnv(cwd)
}
env := []string{"OLLIE_SESSION_ID=" + sessID, "OLLIE_UNAME=" + uname}
env = append(env, promptEnv...)
// Compute base layers (same as new-session path).
var spOverride string
if cfg != nil {
spOverride = cfg.SystemPrompt
}
sysPrompt := prompts.ResolveSystemPrompt(spOverride)
envMap := make(map[string]string)
for _, e := range env {
if k, v, ok := strings.Cut(e, "="); ok {
envMap[k] = v
}
}
opModel := prompts.OperationalModel(s.cfg.Enable9P, s.cfg.EnableDBus, envMap)
platform := "linux"
isGitRepo := false
for _, e := range promptEnv {
if k, v, ok := strings.Cut(e, "="); ok {
switch k {
case "PRIME_PLATFORM":
platform = v
case "PRIME_IS_GIT_REPO":
isGitRepo = v == "true"
}
}
}
envBlock := prompts.Environment(cwd, platform, isGitRepo, "")
disp := newDisp()
rt := agent.BuildRuntime(cfg, disp, cwd, env, sysPrompt, opModel, envBlock)
restoredSession := agent.RestoreHistory(ps)
var sessPtr *Session
core := agent.New(agent.Config{
Backend: be,
AgentName: agentName,
AgentsDir: s.cfg.AgentsDir,
SessionsDir: s.cfg.SessionsDir,
SessionID: sessID,
Uname: uname,
CWD: cwd,
Remote: remoteTarget,
History: restoredSession,
Runtime: rt,
NewDispatcher: newDisp,
PromptEnvExtra: promptEnv,
BaseLayers: []string{sysPrompt, opModel, envBlock},
Log: s.cfg.Sink.NewLogger("core"),
ReadPlanStep: func() string {
if sessPtr == nil {
return ""
}
sessPtr.mu.RLock()
data := make([]byte, len(sessPtr.plan))
copy(data, sessPtr.plan)
sessPtr.mu.RUnlock()
return agent.NextUncheckedStep(data)
},
})
ctx, cancel := context.WithCancel(context.Background())
sess := NewSession(sessID, core, ctx, cancel)
sessPtr = sess
sess.uname = uname
sess.remote = remoteTarget
// Replay tail of persisted messages into the chat log so the GUI
// and `chat` file show recent history on restore.
replayMessagesToLog(sess, ps.Messages)
s.mu.Lock()
s.sessions[sessID] = sess
s.mu.Unlock()
s.cfg.Log.Info("restored session %s (backend=%s model=%s agent=%s)", sessID, backendName, modelName, agentName)
if s.cfg.OnSessionCreated != nil {
s.cfg.OnSessionCreated(sessID, sess)
}
return nil
}
func (s *Manager) Shutdown() {
// Interrupt all in-progress turns and wait for them to finish
// before persisting state, so we capture the latest messages.
s.InterruptAll()
s.waitIdle(100*time.Millisecond, 5*time.Second)
s.saveAllSessions()
s.mu.Lock()
ids := make([]string, 0, len(s.sessions))
for id := range s.sessions {
ids = append(ids, id)
}
s.mu.Unlock()
for _, id := range ids {
s.mu.Lock()
sess := s.sessions[id]
delete(s.sessions, id)
s.mu.Unlock()
if sess != nil {
sess.Cancel()
sess.Core.Close()
s.cfg.Log.Info("shutdown session %s", id)
}
}
}
// waitIdle polls all sessions until they are idle or the timeout expires.
func (s *Manager) waitIdle(poll, timeout time.Duration) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
allIdle := true
s.mu.RLock()
for _, sess := range s.sessions {
if sess.Core.State() != "idle" {
allIdle = false
break
}
}
s.mu.RUnlock()
if allIdle {
return
}
time.Sleep(poll)
}
s.cfg.Log.Warn("shutdown: timed out waiting for sessions to become idle, saving anyway")
}
func (s *Manager) KillSession(id string) {
s.mu.Lock()
sess := s.sessions[id]
delete(s.sessions, id)
s.mu.Unlock()
if sess != nil {
sess.Cancel()
sess.Core.Close()
s.removePersistedSession(id)
s.cfg.Log.Info("killed session %s", id)
if s.cfg.OnSessionKilled != nil {
s.cfg.OnSessionKilled(id)
}
}
}
func (s *Manager) index() []byte {
var sb strings.Builder
s.mu.RLock()
ids := make([]string, 0, len(s.sessions))
for id := range s.sessions {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
sess := s.sessions[id]
sess.mu.RLock()
state := sess.Core.State()
cwd := sess.Core.CWD()
be := sess.Core.BackendName()
model := sess.Core.ModelName()
agent := sess.Core.AgentName()
sess.mu.RUnlock()
fmt.Fprintf(&sb, "%s\t%s\t%s\t%s\t%s\t%s\n", id, state, cwd, be, model, agent)
}
s.mu.RUnlock()
return []byte(sb.String())
}
// CreateSession creates a new agent session from key=value args.
// Returns the session ID on success.

File diff suppressed because it is too large Load Diff