From fc5443e3145c2a008178ebdcdde7b9f82e9c5be7 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Mon, 13 Apr 2026 14:25:53 +0200 Subject: [PATCH] 9p: session rename via wstat, ctl kill/rn, connection tracking - Support mv on session directories (wstat rename) - Track connections globally for fid path rewriting on rename - Add kill and rn to ctl handler - Append rename notification to chat log - Update README with rename and ctl docs --- README.md | 18 +++++++- internal/p9/server.go | 99 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 105 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 3ecc908..3a09c2d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ ollie/ pl/ dir: plans s/ dir: one entry per active session, sorted by creation time new r/w: read: KV template; write: create session - / rm -r to kill session + / rm -r to kill session; mv to rename prompt write: submit a prompt to the agent enqueue write: queue a prompt for later execution dequeue read: pop the next queued prompt @@ -98,10 +98,12 @@ cat ~/mnt/ollie/s//state echo stop > ~/mnt/ollie/s//ctl # interrupt the current turn echo compact > ~/mnt/ollie/s//ctl # summarize context echo clear > ~/mnt/ollie/s//ctl # clear session history +echo kill > ~/mnt/ollie/s//ctl # kill session +echo "rn my-name" > ~/mnt/ollie/s//ctl # rename session echo "model qwen3:8b" > ~/mnt/ollie/s//ctl ``` -`ctl` accepts `stop` or any command supported by the agent (the `/` prefix is added automatically). +`ctl` accepts `stop`, `kill`, `rn `, or any command supported by the agent (the `/` prefix is added automatically). ### Switch backend, model, or agent @@ -119,6 +121,14 @@ Writes to `backend`, `model`, and `agent` are rejected with an error when the ag rm -r ~/mnt/ollie/s/ ``` +### Rename a session + +```sh +mv ~/mnt/ollie/s/ ~/mnt/ollie/s/my-friendly-name +``` + +Rename is rejected if the agent is running or the target name already exists. All open file handles into the session are updated automatically. + ## Example shell session ```sh @@ -137,4 +147,8 @@ pkg/agent/loop.go assistant: The Go source files are: core.go, loop.go, ... $ cat state idle +$ mv ~/mnt/ollie/s/1744276689123456789-2b986c ~/mnt/ollie/s/ollie-demo +$ ls ~/mnt/ollie/s/ +new +ollie-demo ``` diff --git a/internal/p9/server.go b/internal/p9/server.go index 95e6bed..620c682 100644 --- a/internal/p9/server.go +++ b/internal/p9/server.go @@ -7,7 +7,7 @@ // pl/ (dir) plans // s/ (dir) session directory // new (r/w) read: KV template; write: create session -// {session-id}/ rm -r to kill session +// {session-id}/ rm -r to kill session; mv to rename // ctl (write) session control: compact, clear, interrupt // prompt (write) submit a prompt to the agent // enqueue (write) queue a prompt for later execution @@ -90,6 +90,7 @@ type connState struct { type Server struct { mu sync.RWMutex sessions map[string]*session + conns []*connState agentsDir string sessionsDir string promptsDir string @@ -120,6 +121,19 @@ func defaultPlanDir() string { func (s *Server) Serve(conn net.Conn) { defer conn.Close() cs := &connState{fids: make(map[uint32]*fid)} + s.mu.Lock() + s.conns = append(s.conns, cs) + s.mu.Unlock() + defer func() { + s.mu.Lock() + for i, c := range s.conns { + if c == cs { + s.conns = append(s.conns[:i], s.conns[i+1:]...) + break + } + } + s.mu.Unlock() + }() for { fc, err := plan9.ReadFcall(conn) if err != nil { @@ -693,11 +707,6 @@ func (s *Server) wstat(cs *connState, fc *plan9.Fcall) *plan9.Fcall { return errFcall(fc, "bad fid") } - // Only /pl files support rename via wstat. - if !strings.HasPrefix(f.path, "/pl/") { - return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag} - } - // Parse the new Dir from the stat bytes. newDir, err := plan9.UnmarshalDir(fc.Stat) if err != nil { @@ -705,9 +714,13 @@ func (s *Server) wstat(cs *connState, fc *plan9.Fcall) *plan9.Fcall { return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag} } - // If the name changed, rename the file on disk. oldName := pathBase(f.path) - if newDir.Name != "" && newDir.Name != oldName { + if newDir.Name == "" || newDir.Name == oldName { + return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag} + } + + switch { + case strings.HasPrefix(f.path, "/pl/"): oldPath := s.planDir + "/" + oldName newPath := s.planDir + "/" + newDir.Name if err := os.Rename(oldPath, newPath); err != nil { @@ -717,11 +730,69 @@ func (s *Server) wstat(cs *connState, fc *plan9.Fcall) *plan9.Fcall { f.path = "/pl/" + newDir.Name f.qid.Path = qidPath(f.path) cs.mu.Unlock() + + case strings.HasPrefix(f.path, "/s/") && f.path != "/s/new": + // Session directory rename: /s/{old} -> /s/{new} + parts := strings.SplitN(strings.TrimPrefix(f.path, "/"), "/", 3) + if len(parts) != 2 || parts[0] != "s" { + return errFcall(fc, "rename not supported") + } + if err := s.renameSession(parts[1], newDir.Name); err != nil { + return errFcall(fc, err.Error()) + } + + default: + return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag} } return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag} } +// renameSession re-keys a session in the sessions map, updates the core's +// session ID, and rewrites fid paths across all connections. +// Caller must hold NO locks on s.mu (this method acquires it). +func (s *Server) renameSession(oldID, newID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + sess, ok := s.sessions[oldID] + if !ok { + return fmt.Errorf("session not found: %s", oldID) + } + if _, exists := s.sessions[newID]; exists { + return fmt.Errorf("session already exists: %s", newID) + } + if sess.core.IsRunning() { + return fmt.Errorf("cannot rename while agent is running") + } + + if err := sess.core.SetSessionID(newID); err != nil { + return err + } + + sess.id = newID + s.sessions[newID] = sess + delete(s.sessions, oldID) + + // Rewrite fid paths across all connections. + oldPrefix := "/s/" + oldID + newPrefix := "/s/" + newID + for _, c := range s.conns { + c.mu.Lock() + for _, f := range c.fids { + if f.path == oldPrefix || strings.HasPrefix(f.path, oldPrefix+"/") { + f.path = newPrefix + f.path[len(oldPrefix):] + f.qid.Path = qidPath(f.path) + } + } + c.mu.Unlock() + } + + sess.appendChat([]byte(fmt.Sprintf("(session renamed: %s -> %s)\n", oldID, newID))) + fmt.Fprintf(os.Stderr, "olliesrv: renamed session %s -> %s\n", oldID, newID) + return nil +} + func (s *Server) clunk(cs *connState, fc *plan9.Fcall) *plan9.Fcall { cs.mu.Lock() f, ok := cs.fids[fc.Fid] @@ -888,9 +959,17 @@ func (s *Server) handleWrite(path, input string) error { return nil case "ctl": - switch input { - case "stop": + switch { + case input == "stop": sess.core.Interrupt(agent.ErrInterrupted) + case input == "kill": + s.killSession(sessID) + case strings.HasPrefix(input, "rn "): + if name := strings.TrimSpace(input[3:]); name != "" { + if err := s.renameSession(sessID, name); err != nil { + fmt.Fprintf(os.Stderr, "olliesrv: rename: %v\n", err) + } + } default: sess.core.Submit(sess.ctx, "/"+input, publish) }