1701 lines
45 KiB
Go
1701 lines
45 KiB
Go
// Package p9 implements the 9P filesystem for ollie sessions.
|
|
//
|
|
// Filesystem layout:
|
|
//
|
|
// ollie/
|
|
// a/ (dir) agent configs (r/w, backed by ~/.config/ollie/agents/)
|
|
// backends (read) list of ollie-provided backends
|
|
// help (read) help file (backed by ~/.config/ollie/help.md)
|
|
// m/ (dir) memories (r/w, backed by OLLIE_MEMORY_PATH)
|
|
// p/ (dir) prompt templates
|
|
// pl/ (dir) plans
|
|
// s/ (dir) session directory
|
|
// new (r/w) read: KV template; write: create session
|
|
// idx (read) live index: name state cwd backend model (tab-separated)
|
|
// {session-id}/ rm -r to kill session; mv to rename
|
|
// ctl (write) session control: stop, kill, rn, compact, clear
|
|
// prompt (write) submit a prompt to the agent
|
|
// enqueue (write) queue a prompt for later execution
|
|
// dequeue (read) pop the next queued prompt
|
|
// chat (read) cumulative chat history
|
|
// reply (read) assistant text from the most recent turn
|
|
// state (read) current agent state
|
|
// backend (r/w) active backend name
|
|
// agent (r/w) active agent name
|
|
// model (r/w) active model name
|
|
// models (read) available models from the backend
|
|
// mcp (read) MCP server list
|
|
// cwd (r/w) working directory
|
|
// usage (read) token counts
|
|
// ctxsz (read) context size vs window
|
|
// sk/ (dir) skills
|
|
// t/ (dir) tool scripts
|
|
package p9
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"9fans.net/go/plan9"
|
|
"ollie/pkg/agent"
|
|
"ollie/pkg/backend"
|
|
"ollie/pkg/config"
|
|
"ollie/pkg/skills"
|
|
"ollie/pkg/tools"
|
|
"ollie/pkg/tools/execute"
|
|
)
|
|
|
|
const (
|
|
QTDir = plan9.QTDIR
|
|
QTFile = plan9.QTFILE
|
|
)
|
|
|
|
// mutableFile tracks version and last-known value for a tailable session file.
|
|
// Qid.Vers is bumped on change so tail -f detects rewrites via stat polling.
|
|
// On change, truncated is set to 1 so the next stat reports Length 0,
|
|
// causing tail to detect truncation and re-read from offset 0.
|
|
type mutableFile struct {
|
|
vers uint32
|
|
last string
|
|
truncated int32 // atomic: 1 after change, cleared by stat
|
|
}
|
|
|
|
func (m *mutableFile) update(val string) {
|
|
if val != m.last {
|
|
m.last = val
|
|
m.vers++
|
|
atomic.StoreInt32(&m.truncated, 1)
|
|
}
|
|
}
|
|
|
|
// session holds all state for one ollie agent session.
|
|
type session struct {
|
|
mu sync.RWMutex
|
|
id string
|
|
core agent.Core
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
// chatLog is an append-only record of the conversation. Reads are served
|
|
// directly from this buffer by offset, so tail -f works via polling.
|
|
chatLog []byte
|
|
chatVers uint32 // incremented on each append; used as Qid.Vers
|
|
// replyVers is incremented on each turn to drive Qid.Vers for polling.
|
|
replyVers uint32
|
|
// mutableVers tracks changes to tailable mutable-state files
|
|
// (state, backend, agent, model, usage, cwd). Bumped on change;
|
|
// reported via Qid.Vers in stat so tail -f detects truncation/rewrite.
|
|
mutableVers map[string]*mutableFile
|
|
}
|
|
|
|
|
|
// appendChat appends data to the session's chat log and bumps the Qid version.
|
|
func (sess *session) appendChat(data []byte) {
|
|
if len(data) == 0 {
|
|
return
|
|
}
|
|
sess.mu.Lock()
|
|
sess.chatLog = append(sess.chatLog, data...)
|
|
sess.chatVers++
|
|
sess.mu.Unlock()
|
|
}
|
|
|
|
// trackMutable snapshots state and usage into their version trackers.
|
|
// Called from the publish callback after each event.
|
|
func (sess *session) trackMutable() {
|
|
state := sess.core.State()
|
|
usage := sess.core.Usage()
|
|
sess.mu.Lock()
|
|
sess.mutableVers["state"].update(state)
|
|
sess.mutableVers["usage"].update(usage)
|
|
sess.mu.Unlock()
|
|
}
|
|
|
|
// fid tracks per-descriptor state for a single 9P connection.
|
|
type fid struct {
|
|
path string
|
|
qid plan9.Qid
|
|
mode uint8
|
|
writeBuf []byte
|
|
}
|
|
|
|
// connState tracks all open fids for a single 9P connection.
|
|
type connState struct {
|
|
mu sync.RWMutex
|
|
fids map[uint32]*fid
|
|
}
|
|
|
|
// Server is the 9P server for ollie sessions.
|
|
type Server struct {
|
|
mu sync.RWMutex
|
|
sessions map[string]*session
|
|
conns []*connState
|
|
agentsDir string
|
|
sessionsDir string
|
|
promptsDir string
|
|
planDir string // planning directory for /pl
|
|
memDir string // memory directory for /m
|
|
}
|
|
|
|
// New creates a new Server.
|
|
func New() *Server {
|
|
memDir := defaultMemDir()
|
|
os.MkdirAll(memDir, 0755) //nolint:errcheck
|
|
return &Server{
|
|
sessions: make(map[string]*session),
|
|
agentsDir: agent.DefaultAgentsDir(),
|
|
sessionsDir: agent.DefaultSessionsDir(),
|
|
promptsDir: agent.DefaultPromptsDir(),
|
|
planDir: defaultPlanDir(),
|
|
memDir: memDir,
|
|
}
|
|
}
|
|
|
|
// defaultPlanDir returns the planning directory from OLLIE_PLAN_PATH or the default.
|
|
func defaultPlanDir() string {
|
|
if p := os.Getenv("OLLIE_PLAN_PATH"); p != "" {
|
|
return p
|
|
}
|
|
home, _ := os.UserHomeDir()
|
|
return home + "/.config/ollie/planning"
|
|
}
|
|
|
|
// defaultMemDir returns the memory directory from OLLIE_MEMORY_PATH or the default.
|
|
func defaultMemDir() string {
|
|
if p := os.Getenv("OLLIE_MEMORY_PATH"); p != "" {
|
|
return p
|
|
}
|
|
home, _ := os.UserHomeDir()
|
|
return home + "/.config/ollie/memory"
|
|
}
|
|
|
|
// Serve handles a single 9P connection.
|
|
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 {
|
|
if err != io.EOF {
|
|
fmt.Fprintf(os.Stderr, "olliesrv: read: %v\n", err)
|
|
}
|
|
return
|
|
}
|
|
plan9.WriteFcall(conn, s.handle(cs, fc)) //nolint:errcheck
|
|
}
|
|
}
|
|
|
|
func (s *Server) handle(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|
switch fc.Type {
|
|
case plan9.Tversion:
|
|
msize := fc.Msize
|
|
if msize > 65536 {
|
|
msize = 65536
|
|
}
|
|
return &plan9.Fcall{Type: plan9.Rversion, Tag: fc.Tag, Msize: msize, Version: "9P2000"}
|
|
case plan9.Tauth:
|
|
return errFcall(fc, "no auth required")
|
|
case plan9.Tattach:
|
|
return s.attach(cs, fc)
|
|
case plan9.Twalk:
|
|
return s.walk(cs, fc)
|
|
case plan9.Topen:
|
|
return s.open(cs, fc)
|
|
case plan9.Tcreate:
|
|
return s.create(cs, fc)
|
|
case plan9.Tread:
|
|
return s.read(cs, fc)
|
|
case plan9.Twrite:
|
|
return s.write(cs, fc)
|
|
case plan9.Tstat:
|
|
return s.stat(cs, fc)
|
|
case plan9.Twstat:
|
|
return s.wstat(cs, fc)
|
|
case plan9.Tflush:
|
|
// No blocking reads to cancel; always succeed.
|
|
return &plan9.Fcall{Type: plan9.Rflush, Tag: fc.Tag}
|
|
case plan9.Tclunk:
|
|
return s.clunk(cs, fc)
|
|
case plan9.Tremove:
|
|
return s.remove(cs, fc)
|
|
default:
|
|
return errFcall(fc, "unsupported operation")
|
|
}
|
|
}
|
|
|
|
func errFcall(fc *plan9.Fcall, msg string) *plan9.Fcall {
|
|
return &plan9.Fcall{Type: plan9.Rerror, Tag: fc.Tag, Ename: msg}
|
|
}
|
|
|
|
// qidPath returns a stable numeric path for use in Qid structs.
|
|
func qidPath(path string) uint64 {
|
|
if path == "/" {
|
|
return 0
|
|
}
|
|
h := fnv.New64a()
|
|
h.Write([]byte(path))
|
|
return h.Sum64()
|
|
}
|
|
|
|
// pathType returns "dir", "file", or "" (not found) for a logical path.
|
|
func (s *Server) pathType(path string) string {
|
|
if path == "/" {
|
|
return "dir"
|
|
}
|
|
trimmed := strings.TrimPrefix(path, "/")
|
|
parts := strings.SplitN(trimmed, "/", 3)
|
|
switch {
|
|
case len(parts) == 1 && parts[0] == "s":
|
|
return "dir"
|
|
case len(parts) == 2 && parts[0] == "s" && parts[1] == "new":
|
|
return "file"
|
|
case len(parts) == 2 && parts[0] == "s" && parts[1] == "idx":
|
|
return "file"
|
|
case len(parts) == 1 && parts[0] == "a":
|
|
return "dir"
|
|
case len(parts) == 1 && parts[0] == "p":
|
|
return "dir"
|
|
case len(parts) == 1 && parts[0] == "m":
|
|
return "dir"
|
|
case len(parts) == 1 && parts[0] == "pl":
|
|
return "dir"
|
|
case len(parts) == 1 && parts[0] == "sk":
|
|
return "dir"
|
|
case len(parts) == 1 && parts[0] == "t":
|
|
return "dir"
|
|
case len(parts) == 1 && parts[0] == "backends":
|
|
return "file"
|
|
case len(parts) == 1 && parts[0] == "help":
|
|
return "file"
|
|
case len(parts) == 2 && parts[0] == "a":
|
|
if _, err := os.Stat(s.agentsDir + "/" + parts[1]); err == nil {
|
|
return "file"
|
|
}
|
|
case len(parts) == 2 && parts[0] == "p":
|
|
if _, err := os.Stat(s.promptsDir + "/" + parts[1]); err == nil {
|
|
return "file"
|
|
}
|
|
case len(parts) == 2 && parts[0] == "m":
|
|
if _, err := os.Stat(s.memDir + "/" + parts[1]); err == nil {
|
|
return "file"
|
|
}
|
|
case len(parts) == 2 && parts[0] == "pl":
|
|
if _, err := os.Stat(s.planDir + "/" + parts[1]); err == nil {
|
|
return "file"
|
|
}
|
|
case len(parts) == 2 && parts[0] == "sk":
|
|
if parts[1] == "idx" {
|
|
return "file"
|
|
}
|
|
name := strings.TrimSuffix(parts[1], ".md")
|
|
if _, err := skills.Read(name); err == nil {
|
|
return "file"
|
|
}
|
|
case len(parts) == 2 && parts[0] == "t":
|
|
if parts[1] == "idx" {
|
|
return "file"
|
|
}
|
|
if _, err := os.Stat(execute.ToolsPath() + "/" + parts[1]); err == nil {
|
|
return "file"
|
|
}
|
|
case len(parts) == 2 && parts[0] == "s":
|
|
s.mu.RLock()
|
|
_, ok := s.sessions[parts[1]]
|
|
s.mu.RUnlock()
|
|
if ok {
|
|
return "dir"
|
|
}
|
|
case len(parts) == 3 && parts[0] == "s":
|
|
s.mu.RLock()
|
|
_, ok := s.sessions[parts[1]]
|
|
s.mu.RUnlock()
|
|
if !ok {
|
|
return ""
|
|
}
|
|
switch parts[2] {
|
|
case "ctl", "prompt", "enqueue", "dequeue", "chat", "reply", "backend", "agent", "model", "models", "mcp", "state", "cwd", "usage", "ctxsz":
|
|
return "file"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func pathParent(path string) string {
|
|
if path == "/" {
|
|
return "/"
|
|
}
|
|
i := strings.LastIndex(path, "/")
|
|
if i == 0 {
|
|
return "/"
|
|
}
|
|
return path[:i]
|
|
}
|
|
|
|
func pathJoin(parent, name string) string {
|
|
if parent == "/" {
|
|
return "/" + name
|
|
}
|
|
return parent + "/" + name
|
|
}
|
|
|
|
func pathBase(path string) string {
|
|
i := strings.LastIndex(path, "/")
|
|
if i < 0 {
|
|
return path
|
|
}
|
|
return path[i+1:]
|
|
}
|
|
|
|
func (s *Server) attach(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|
cs.mu.Lock()
|
|
defer cs.mu.Unlock()
|
|
qid := plan9.Qid{Type: QTDir, Path: 0}
|
|
cs.fids[fc.Fid] = &fid{path: "/", qid: qid}
|
|
return &plan9.Fcall{Type: plan9.Rattach, Tag: fc.Tag, Qid: qid}
|
|
}
|
|
|
|
func (s *Server) walk(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|
cs.mu.Lock()
|
|
defer cs.mu.Unlock()
|
|
|
|
f, ok := cs.fids[fc.Fid]
|
|
if !ok {
|
|
return errFcall(fc, "bad fid")
|
|
}
|
|
|
|
// Clone: copy the fid to newfid (even when wnames is empty).
|
|
newf := &fid{path: f.path, qid: f.qid}
|
|
|
|
if len(fc.Wname) == 0 {
|
|
cs.fids[fc.Newfid] = newf
|
|
return &plan9.Fcall{Type: plan9.Rwalk, Tag: fc.Tag, Wqid: []plan9.Qid{}}
|
|
}
|
|
|
|
wqids := make([]plan9.Qid, 0, len(fc.Wname))
|
|
cur := f.path
|
|
|
|
for _, name := range fc.Wname {
|
|
var next string
|
|
if name == ".." {
|
|
next = pathParent(cur)
|
|
} else {
|
|
next = pathJoin(cur, name)
|
|
}
|
|
|
|
t := s.pathType(next)
|
|
if t == "" {
|
|
if len(wqids) == 0 {
|
|
return errFcall(fc, name+": file not found")
|
|
}
|
|
break
|
|
}
|
|
|
|
q := plan9.Qid{Path: qidPath(next)}
|
|
if t == "dir" {
|
|
q.Type = QTDir
|
|
}
|
|
wqids = append(wqids, q)
|
|
cur = next
|
|
}
|
|
|
|
if len(wqids) == len(fc.Wname) {
|
|
newf.path = cur
|
|
newf.qid = wqids[len(wqids)-1]
|
|
cs.fids[fc.Newfid] = newf
|
|
}
|
|
|
|
return &plan9.Fcall{Type: plan9.Rwalk, Tag: fc.Tag, Wqid: wqids}
|
|
}
|
|
|
|
func (s *Server) open(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|
cs.mu.Lock()
|
|
defer cs.mu.Unlock()
|
|
|
|
f, ok := cs.fids[fc.Fid]
|
|
if !ok {
|
|
return errFcall(fc, "bad fid")
|
|
}
|
|
|
|
f.mode = fc.Mode
|
|
return &plan9.Fcall{Type: plan9.Ropen, Tag: fc.Tag, Qid: f.qid}
|
|
}
|
|
|
|
func (s *Server) create(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|
cs.mu.Lock()
|
|
defer cs.mu.Unlock()
|
|
|
|
f, ok := cs.fids[fc.Fid]
|
|
if !ok {
|
|
return errFcall(fc, "bad fid")
|
|
}
|
|
if f.path != "/a" && f.path != "/p" && f.path != "/pl" && f.path != "/sk" && f.path != "/t" {
|
|
return errFcall(fc, "create not supported")
|
|
}
|
|
if fc.Perm&plan9.DMDIR != 0 {
|
|
return errFcall(fc, "mkdir not supported")
|
|
}
|
|
|
|
newPath := pathJoin(f.path, fc.Name)
|
|
|
|
// Actually create the file on disk so that even a no-write create
|
|
// (e.g. touch) produces a real file.
|
|
switch f.path {
|
|
case "/a":
|
|
os.MkdirAll(s.agentsDir, 0755) //nolint:errcheck
|
|
if err := os.WriteFile(s.agentsDir+"/"+fc.Name, nil, 0644); err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
case "/p":
|
|
os.MkdirAll(s.promptsDir, 0755) //nolint:errcheck
|
|
if err := os.WriteFile(s.promptsDir+"/"+fc.Name, nil, 0644); err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
case "/m":
|
|
os.MkdirAll(s.memDir, 0755) //nolint:errcheck
|
|
if err := os.WriteFile(s.memDir+"/"+fc.Name, nil, 0644); err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
case "/pl":
|
|
os.MkdirAll(s.planDir, 0755) //nolint:errcheck
|
|
if err := os.WriteFile(s.planDir+"/"+fc.Name, nil, 0644); err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
case "/t":
|
|
dir := execute.ToolsPath()
|
|
os.MkdirAll(dir, 0755) //nolint:errcheck
|
|
if err := os.WriteFile(dir+"/"+fc.Name, nil, 0755); err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
case "/sk":
|
|
name := strings.TrimSuffix(fc.Name, ".md")
|
|
dir := skills.Dirs()[0] + "/" + name
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
if err := os.WriteFile(dir+"/SKILL.md", nil, 0644); err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
}
|
|
|
|
qid := plan9.Qid{Path: qidPath(newPath)}
|
|
f.path = newPath
|
|
f.qid = qid
|
|
f.mode = fc.Mode
|
|
return &plan9.Fcall{Type: plan9.Rcreate, Tag: fc.Tag, Qid: qid}
|
|
}
|
|
|
|
func (s *Server) read(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|
cs.mu.RLock()
|
|
f, ok := cs.fids[fc.Fid]
|
|
if !ok {
|
|
cs.mu.RUnlock()
|
|
return errFcall(fc, "bad fid")
|
|
}
|
|
path := f.path
|
|
isDir := f.qid.Type&QTDir != 0
|
|
cs.mu.RUnlock()
|
|
|
|
if isDir {
|
|
data := s.readDir(path, fc.Offset, fc.Count)
|
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
|
|
}
|
|
|
|
// chat, reply, and dequeue are served from session state.
|
|
switch pathBase(path) {
|
|
case "chat":
|
|
return s.readChat(fc, path)
|
|
case "reply":
|
|
return s.readReply(fc, path)
|
|
case "dequeue":
|
|
return s.readDequeue(fc, path)
|
|
}
|
|
|
|
// s/new returns the session creation template.
|
|
if path == "/s/new" {
|
|
content := []byte("name=\ncwd=\nbackend=\nmodel=\nagent=\n")
|
|
return s.readSlice(fc, content)
|
|
}
|
|
|
|
// s/idx is a live index of all sessions: name state cwd backend model.
|
|
if path == "/s/idx" {
|
|
var sb strings.Builder
|
|
s.mu.RLock()
|
|
for id, sess := range s.sessions {
|
|
sess.mu.RLock()
|
|
state := sess.core.State()
|
|
cwd := sess.core.CWD()
|
|
be := sess.core.BackendName()
|
|
model := sess.core.ModelName()
|
|
sess.mu.RUnlock()
|
|
fmt.Fprintf(&sb, "%s\t%s\t%s\t%s\t%s\n", id, state, cwd, be, model)
|
|
}
|
|
s.mu.RUnlock()
|
|
return s.readSlice(fc, []byte(sb.String()))
|
|
}
|
|
|
|
// backends is a static list of ollie-provided backends.
|
|
if path == "/backends" {
|
|
content := []byte(strings.Join(backend.Backends(), "\n") + "\n")
|
|
return s.readSlice(fc, content)
|
|
}
|
|
|
|
// help is served from ~/.config/ollie/help.md.
|
|
if path == "/help" {
|
|
content, err := os.ReadFile(s.helpPath())
|
|
if err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
return s.readSlice(fc, content)
|
|
}
|
|
|
|
// Agent config files are served from disk.
|
|
if strings.HasPrefix(path, "/a/") {
|
|
content, err := os.ReadFile(s.agentsDir + "/" + pathBase(path))
|
|
if err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
return s.readSlice(fc, content)
|
|
}
|
|
|
|
// Prompt files are served from disk.
|
|
if strings.HasPrefix(path, "/p/") {
|
|
content, err := os.ReadFile(s.promptsDir + "/" + pathBase(path))
|
|
if err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
return s.readSlice(fc, content)
|
|
}
|
|
|
|
// Memory files are served from disk.
|
|
if strings.HasPrefix(path, "/m/") {
|
|
content, err := os.ReadFile(s.memDir + "/" + pathBase(path))
|
|
if err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
return s.readSlice(fc, content)
|
|
}
|
|
|
|
// Plan files are served from disk.
|
|
if strings.HasPrefix(path, "/pl/") {
|
|
content, err := os.ReadFile(s.planDir + "/" + pathBase(path))
|
|
if err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
return s.readSlice(fc, content)
|
|
}
|
|
|
|
// Skill files are served via pkg/skills (reads from disk each time).
|
|
if strings.HasPrefix(path, "/sk/") {
|
|
if pathBase(path) == "idx" {
|
|
var sb strings.Builder
|
|
for _, m := range skills.List() {
|
|
fmt.Fprintf(&sb, "## %s\n", m.Name)
|
|
fmt.Fprintf(&sb, "description: %s\n", m.Description)
|
|
sb.WriteString("\n")
|
|
}
|
|
return s.readSlice(fc, []byte(sb.String()))
|
|
}
|
|
name := strings.TrimSuffix(pathBase(path), ".md")
|
|
content, err := skills.Read(name)
|
|
if err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
return s.readSlice(fc, content)
|
|
}
|
|
|
|
// Tool files are served from the tools path.
|
|
if strings.HasPrefix(path, "/t/") {
|
|
if pathBase(path) == "idx" {
|
|
toolsPath := execute.ToolsPath()
|
|
entries, err := os.ReadDir(toolsPath)
|
|
if err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
var sb strings.Builder
|
|
for _, e := range entries {
|
|
if e.IsDir() || e.Name() == "idx" {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(toolsPath + "/" + e.Name())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var desc, args string
|
|
for line := range strings.SplitSeq(string(data), "\n") {
|
|
if d, ok := strings.CutPrefix(line, "# description:"); ok {
|
|
desc = strings.TrimSpace(d)
|
|
} else if a, ok := strings.CutPrefix(line, "# Args:"); ok {
|
|
args = strings.TrimSpace(a)
|
|
} else if a, ok := strings.CutPrefix(line, "# args:"); ok {
|
|
args = strings.TrimSpace(a)
|
|
}
|
|
if !strings.HasPrefix(line, "#") && line != "" {
|
|
break
|
|
}
|
|
}
|
|
if desc == "" {
|
|
continue
|
|
}
|
|
fmt.Fprintf(&sb, "## %s\n", e.Name())
|
|
fmt.Fprintf(&sb, "description: %s\n", desc)
|
|
if args != "" {
|
|
fmt.Fprintf(&sb, "args: %s\n", args)
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
return s.readSlice(fc, []byte(sb.String()))
|
|
}
|
|
content, err := os.ReadFile(execute.ToolsPath() + "/" + pathBase(path))
|
|
if err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
return s.readSlice(fc, content)
|
|
}
|
|
|
|
content := s.readFile(path)
|
|
var data []byte
|
|
off := int(fc.Offset)
|
|
if off < len(content) {
|
|
end := off + int(fc.Count)
|
|
if end > len(content) {
|
|
end = len(content)
|
|
}
|
|
data = []byte(content[off:end])
|
|
}
|
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
|
|
}
|
|
|
|
// readChat serves bytes from the session's chatLog at the requested offset.
|
|
// Returns 0 bytes when offset is at or past the current end (normal EOF);
|
|
// tail -f detects growth via stat and re-reads from its last position.
|
|
func (s *Server) readChat(fc *plan9.Fcall, path string) *plan9.Fcall {
|
|
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
|
|
if len(parts) != 3 || parts[0] != "s" {
|
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
|
|
}
|
|
s.mu.RLock()
|
|
sess := s.sessions[parts[1]]
|
|
s.mu.RUnlock()
|
|
if sess == nil {
|
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
|
|
}
|
|
|
|
sess.mu.RLock()
|
|
log := sess.chatLog
|
|
sess.mu.RUnlock()
|
|
|
|
var data []byte
|
|
off := int(fc.Offset)
|
|
if off < len(log) {
|
|
end := off + int(fc.Count)
|
|
if end > len(log) {
|
|
end = len(log)
|
|
}
|
|
data = log[off:end]
|
|
}
|
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
|
|
}
|
|
|
|
// readDequeue pops the next queued prompt from the session's FIFO.
|
|
// Only pops on offset==0; a non-zero offset signals the EOF read that
|
|
// follows a successful read and must not consume another item.
|
|
func (s *Server) readDequeue(fc *plan9.Fcall, path string) *plan9.Fcall {
|
|
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
|
|
if len(parts) != 3 || parts[0] != "s" {
|
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
|
|
}
|
|
s.mu.RLock()
|
|
sess := s.sessions[parts[1]]
|
|
s.mu.RUnlock()
|
|
if sess == nil {
|
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
|
|
}
|
|
// Non-zero offset is the trailing EOF read; don't pop another item.
|
|
if fc.Offset > 0 {
|
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
|
|
}
|
|
item, ok := sess.core.PopQueue()
|
|
if !ok {
|
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
|
|
}
|
|
data := []byte(item)
|
|
if uint32(len(data)) > fc.Count {
|
|
data = data[:fc.Count]
|
|
}
|
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
|
|
}
|
|
|
|
// readReply serves bytes from the session's reply buffer at the requested offset.
|
|
func (s *Server) readReply(fc *plan9.Fcall, path string) *plan9.Fcall {
|
|
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
|
|
if len(parts) != 3 || parts[0] != "s" {
|
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
|
|
}
|
|
s.mu.RLock()
|
|
sess := s.sessions[parts[1]]
|
|
s.mu.RUnlock()
|
|
if sess == nil {
|
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
|
|
}
|
|
|
|
buf := []byte(sess.core.Reply())
|
|
|
|
var data []byte
|
|
off := int(fc.Offset)
|
|
if off < len(buf) {
|
|
end := off + int(fc.Count)
|
|
if end > len(buf) {
|
|
end = len(buf)
|
|
}
|
|
data = buf[off:end]
|
|
}
|
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
|
|
}
|
|
|
|
// readSlice serves a byte slice at the requested offset/count.
|
|
func (s *Server) readSlice(fc *plan9.Fcall, content []byte) *plan9.Fcall {
|
|
var data []byte
|
|
off := int(fc.Offset)
|
|
if off < len(content) {
|
|
end := off + int(fc.Count)
|
|
if end > len(content) {
|
|
end = len(content)
|
|
}
|
|
data = content[off:end]
|
|
}
|
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
|
|
}
|
|
|
|
func (s *Server) helpPath() string {
|
|
home, _ := os.UserHomeDir()
|
|
return home + "/.config/ollie/help.md"
|
|
}
|
|
|
|
// readFile returns the text content for a readable session file.
|
|
func (s *Server) readFile(path string) string {
|
|
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
|
|
if len(parts) != 3 || parts[0] != "s" {
|
|
return ""
|
|
}
|
|
sessID, fileName := parts[1], parts[2]
|
|
|
|
s.mu.RLock()
|
|
sess := s.sessions[sessID]
|
|
s.mu.RUnlock()
|
|
if sess == nil {
|
|
return ""
|
|
}
|
|
|
|
sess.mu.RLock()
|
|
defer sess.mu.RUnlock()
|
|
switch fileName {
|
|
case "backend":
|
|
return sess.core.BackendName() + "\n"
|
|
case "agent":
|
|
return sess.core.AgentName() + "\n"
|
|
case "model":
|
|
return sess.core.ModelName() + "\n"
|
|
case "state":
|
|
return sess.core.State() + "\n"
|
|
case "cwd":
|
|
return sess.core.CWD() + "\n"
|
|
case "usage":
|
|
return sess.core.Usage() + "\n"
|
|
case "ctxsz":
|
|
return sess.core.CtxSz() + "\n"
|
|
case "models":
|
|
return sess.core.ListModels() + "\n"
|
|
case "mcp":
|
|
return sess.core.ListServers() + "\n"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|
cs.mu.Lock()
|
|
f, ok := cs.fids[fc.Fid]
|
|
if !ok {
|
|
cs.mu.Unlock()
|
|
return errFcall(fc, "bad fid")
|
|
}
|
|
// Accumulate; the 9P client may split large writes across multiple Twrite messages.
|
|
end := int(fc.Offset) + len(fc.Data)
|
|
if end > len(f.writeBuf) {
|
|
grown := make([]byte, end)
|
|
copy(grown, f.writeBuf)
|
|
f.writeBuf = grown
|
|
}
|
|
copy(f.writeBuf[fc.Offset:], fc.Data)
|
|
cs.mu.Unlock()
|
|
return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))}
|
|
}
|
|
|
|
func (s *Server) stat(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|
cs.mu.RLock()
|
|
f, ok := cs.fids[fc.Fid]
|
|
cs.mu.RUnlock()
|
|
if !ok {
|
|
return errFcall(fc, "bad fid")
|
|
}
|
|
dir := s.makeStat(f.path)
|
|
stat, err := dir.Bytes()
|
|
if err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
return &plan9.Fcall{Type: plan9.Rstat, Tag: fc.Tag, Stat: stat}
|
|
}
|
|
|
|
func (s *Server) wstat(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|
cs.mu.Lock()
|
|
f, ok := cs.fids[fc.Fid]
|
|
cs.mu.Unlock()
|
|
if !ok {
|
|
return errFcall(fc, "bad fid")
|
|
}
|
|
|
|
// Parse the new Dir from the stat bytes.
|
|
newDir, err := plan9.UnmarshalDir(fc.Stat)
|
|
if err != nil {
|
|
// Some clients send minimal wstat (e.g. truncate); accept silently.
|
|
return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag}
|
|
}
|
|
|
|
oldName := pathBase(f.path)
|
|
if newDir.Name == "" || newDir.Name == oldName {
|
|
return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag}
|
|
}
|
|
|
|
switch {
|
|
case strings.HasPrefix(f.path, "/a/"):
|
|
oldPath := s.agentsDir + "/" + oldName
|
|
newPath := s.agentsDir + "/" + newDir.Name
|
|
if err := os.Rename(oldPath, newPath); err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
cs.mu.Lock()
|
|
f.path = "/a/" + newDir.Name
|
|
f.qid.Path = qidPath(f.path)
|
|
cs.mu.Unlock()
|
|
|
|
case strings.HasPrefix(f.path, "/m/"):
|
|
oldPath := s.memDir + "/" + oldName
|
|
newPath := s.memDir + "/" + newDir.Name
|
|
if err := os.Rename(oldPath, newPath); err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
cs.mu.Lock()
|
|
f.path = "/m/" + newDir.Name
|
|
f.qid.Path = qidPath(f.path)
|
|
cs.mu.Unlock()
|
|
|
|
case strings.HasPrefix(f.path, "/pl/"):
|
|
oldPath := s.planDir + "/" + oldName
|
|
newPath := s.planDir + "/" + newDir.Name
|
|
if err := os.Rename(oldPath, newPath); err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
cs.mu.Lock()
|
|
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()
|
|
}
|
|
|
|
// Rename the session tmpdir so isread markers remain valid.
|
|
oldTemp := "/tmp/ollie/" + oldID
|
|
newTemp := "/tmp/ollie/" + newID
|
|
if _, err := os.Stat(oldTemp); err == nil {
|
|
os.Rename(oldTemp, newTemp) //nolint:errcheck
|
|
}
|
|
|
|
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]
|
|
var path string
|
|
var data []byte
|
|
if ok {
|
|
if len(f.writeBuf) > 0 {
|
|
path = f.path
|
|
data = make([]byte, len(f.writeBuf))
|
|
copy(data, f.writeBuf)
|
|
}
|
|
delete(cs.fids, fc.Fid)
|
|
}
|
|
cs.mu.Unlock()
|
|
if len(data) > 0 {
|
|
input := strings.TrimSpace(string(data))
|
|
if s.isAsyncWrite(path) {
|
|
go s.handleWrite(path, input) //nolint:errcheck
|
|
} else if err := s.handleWrite(path, input); err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
}
|
|
return &plan9.Fcall{Type: plan9.Rclunk, Tag: fc.Tag}
|
|
}
|
|
|
|
// isAsyncWrite returns true for paths where writes may block (agent turns)
|
|
// and Rerror is not useful.
|
|
func (s *Server) isAsyncWrite(path string) bool {
|
|
if !strings.HasPrefix(path, "/s/") {
|
|
return false
|
|
}
|
|
switch pathBase(path) {
|
|
case "prompt", "enqueue", "ctl":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Server) remove(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|
cs.mu.Lock()
|
|
f, ok := cs.fids[fc.Fid]
|
|
if ok {
|
|
delete(cs.fids, fc.Fid)
|
|
}
|
|
cs.mu.Unlock()
|
|
if !ok {
|
|
return errFcall(fc, "bad fid")
|
|
}
|
|
|
|
path := f.path
|
|
var err error
|
|
switch {
|
|
case strings.HasPrefix(path, "/a/"):
|
|
err = os.Remove(s.agentsDir + "/" + pathBase(path))
|
|
case strings.HasPrefix(path, "/p/"):
|
|
err = os.Remove(s.promptsDir + "/" + pathBase(path))
|
|
case strings.HasPrefix(path, "/m/"):
|
|
err = os.Remove(s.memDir + "/" + pathBase(path))
|
|
case strings.HasPrefix(path, "/pl/"):
|
|
err = os.Remove(s.planDir + "/" + pathBase(path))
|
|
case strings.HasPrefix(path, "/sk/"):
|
|
name := strings.TrimSuffix(pathBase(path), ".md")
|
|
for _, m := range skills.List() {
|
|
if m.Name == name {
|
|
err = os.RemoveAll(m.Dir)
|
|
break
|
|
}
|
|
}
|
|
case strings.HasPrefix(path, "/t/"):
|
|
err = os.Remove(execute.ToolsPath() + "/" + pathBase(path))
|
|
case strings.HasPrefix(path, "/s/") && path != "/s/new":
|
|
s.killSession(pathBase(path))
|
|
default:
|
|
return errFcall(fc, "remove not supported")
|
|
}
|
|
if err != nil {
|
|
return errFcall(fc, err.Error())
|
|
}
|
|
return &plan9.Fcall{Type: plan9.Rremove, Tag: fc.Tag}
|
|
}
|
|
|
|
// handleWrite processes a fully-assembled write payload for the given path.
|
|
// Called synchronously from clunk; prompt writes are the exception (spawned
|
|
// as a goroutine because they block for the entire agent turn).
|
|
func (s *Server) handleWrite(path, input string) error {
|
|
if input == "" {
|
|
return nil
|
|
}
|
|
|
|
if path == "/s/new" {
|
|
return s.handleNewSession(input)
|
|
}
|
|
|
|
// Prompt file writes go directly to disk.
|
|
if strings.HasPrefix(path, "/p/") {
|
|
return os.WriteFile(s.promptsDir+"/"+pathBase(path), []byte(input), 0644)
|
|
}
|
|
|
|
// Agent config writes go directly to disk.
|
|
if strings.HasPrefix(path, "/a/") {
|
|
return os.WriteFile(s.agentsDir+"/"+pathBase(path), []byte(input), 0644)
|
|
}
|
|
|
|
// Memory file writes go directly to disk.
|
|
if strings.HasPrefix(path, "/m/") {
|
|
os.MkdirAll(s.memDir, 0755) //nolint:errcheck
|
|
return os.WriteFile(s.memDir+"/"+pathBase(path), []byte(input), 0644)
|
|
}
|
|
|
|
// Plan file writes go directly to disk.
|
|
if strings.HasPrefix(path, "/pl/") {
|
|
os.MkdirAll(s.planDir, 0755) //nolint:errcheck
|
|
return os.WriteFile(s.planDir+"/"+pathBase(path), []byte(input), 0644)
|
|
}
|
|
|
|
// Skill file writes: create skill directory and write SKILL.md.
|
|
if strings.HasPrefix(path, "/sk/") {
|
|
name := strings.TrimSuffix(pathBase(path), ".md")
|
|
dir := ""
|
|
for _, m := range skills.List() {
|
|
if m.Name == name {
|
|
dir = m.Dir
|
|
break
|
|
}
|
|
}
|
|
if dir == "" {
|
|
dir = skills.Dirs()[0] + "/" + name
|
|
}
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(dir+"/SKILL.md", []byte(input), 0644)
|
|
}
|
|
|
|
// Tool file writes go directly to disk.
|
|
if strings.HasPrefix(path, "/t/") {
|
|
return os.WriteFile(execute.ToolsPath()+"/"+pathBase(path), []byte(input), 0755)
|
|
}
|
|
|
|
// Path format: /s/{sessid}/{file}
|
|
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
|
|
if len(parts) != 3 || parts[0] != "s" {
|
|
return nil
|
|
}
|
|
sessID, fileName := parts[1], parts[2]
|
|
|
|
s.mu.RLock()
|
|
sess := s.sessions[sessID]
|
|
s.mu.RUnlock()
|
|
if sess == nil {
|
|
return fmt.Errorf("session not found: %s", sessID)
|
|
}
|
|
|
|
// assistantStarted tracks whether we've emitted the "assistant: " label
|
|
// for the current turn. Resets at turn boundaries so each response gets
|
|
// exactly one label regardless of how many streaming chunks arrive.
|
|
assistantStarted := false
|
|
publish := func(ev agent.Event) {
|
|
switch ev.Role {
|
|
case "call", "tool", "newline":
|
|
assistantStarted = false
|
|
case "assistant":
|
|
if !assistantStarted {
|
|
sess.appendChat([]byte("assistant: "))
|
|
assistantStarted = true
|
|
}
|
|
}
|
|
sess.appendChat(formatEvent(ev))
|
|
sess.trackMutable()
|
|
}
|
|
|
|
switch fileName {
|
|
case "prompt":
|
|
sess.core.Submit(sess.ctx, input, publish)
|
|
sess.trackMutable()
|
|
sess.mu.Lock()
|
|
sess.replyVers++
|
|
sess.mu.Unlock()
|
|
return nil
|
|
|
|
case "enqueue":
|
|
sess.core.Queue(input)
|
|
return nil
|
|
|
|
case "ctl":
|
|
cmd := strings.Fields(input)
|
|
if len(cmd) == 0 {
|
|
return fmt.Errorf("empty ctl command")
|
|
}
|
|
switch cmd[0] {
|
|
case "stop":
|
|
sess.core.Interrupt(agent.ErrInterrupted)
|
|
case "kill":
|
|
s.killSession(sessID)
|
|
case "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)
|
|
}
|
|
}
|
|
case "compact", "clear", "backend", "model", "models",
|
|
"agents", "agent", "sessions", "cwd", "skills",
|
|
"tools", "mcp", "context", "usage", "history",
|
|
"irw", "help":
|
|
sess.core.Submit(sess.ctx, "/"+input, publish)
|
|
default:
|
|
return fmt.Errorf("unknown ctl command: %s", cmd[0])
|
|
}
|
|
return nil
|
|
|
|
case "backend":
|
|
if sess.core.IsRunning() {
|
|
return fmt.Errorf("cannot switch backend while agent is running")
|
|
}
|
|
sess.core.Submit(sess.ctx, "/backend "+input, publish)
|
|
sess.mu.Lock()
|
|
sess.mutableVers["backend"].update(sess.core.BackendName())
|
|
sess.mutableVers["model"].update(sess.core.ModelName())
|
|
sess.mu.Unlock()
|
|
return nil
|
|
|
|
case "agent":
|
|
if sess.core.IsRunning() {
|
|
return fmt.Errorf("cannot switch agent while agent is running")
|
|
}
|
|
sess.core.Submit(sess.ctx, "/agent "+input, publish)
|
|
sess.mu.Lock()
|
|
sess.mutableVers["agent"].update(sess.core.AgentName())
|
|
sess.mu.Unlock()
|
|
return nil
|
|
|
|
case "model":
|
|
if input == "" {
|
|
return fmt.Errorf("empty model name")
|
|
}
|
|
if sess.core.IsRunning() {
|
|
return fmt.Errorf("cannot switch model while agent is running")
|
|
}
|
|
sess.core.Submit(sess.ctx, "/model "+input, publish)
|
|
sess.mu.Lock()
|
|
sess.mutableVers["model"].update(sess.core.ModelName())
|
|
sess.mu.Unlock()
|
|
return nil
|
|
|
|
case "cwd":
|
|
if err := sess.core.SetCWD(input); err != nil {
|
|
return err
|
|
}
|
|
sess.mu.Lock()
|
|
sess.mutableVers["cwd"].update(sess.core.CWD())
|
|
sess.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleNewSession parses KV pairs (one per line or space-separated) and creates a session.
|
|
func (s *Server) handleNewSession(input string) error {
|
|
// Accept both newline-separated and space-separated KV pairs.
|
|
args := strings.Fields(input)
|
|
return s.createSession(args)
|
|
}
|
|
|
|
// createSession parses key=value options and starts a new agent session.
|
|
// Recognised keys: name, backend, model, agent, cwd. cwd is required.
|
|
// Example: new cwd=/home/lkn/src/myproject backend=ollama model=qwen3:8b agent=myagent
|
|
func (s *Server) createSession(args []string) error {
|
|
name := ""
|
|
backendOverride := ""
|
|
modelOverride := ""
|
|
agentName := "default"
|
|
cwd := ""
|
|
|
|
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
|
|
default:
|
|
return fmt.Errorf("unknown option %q (valid: name, backend, model, agent, cwd)", k)
|
|
}
|
|
}
|
|
|
|
if cwd == "" {
|
|
return fmt.Errorf("cwd is required (e.g. new cwd=/path/to/project)")
|
|
}
|
|
|
|
var (
|
|
be backend.Backend
|
|
err error
|
|
)
|
|
if backendOverride != "" {
|
|
old := os.Getenv("OLLIE_BACKEND")
|
|
os.Setenv("OLLIE_BACKEND", backendOverride) //nolint:errcheck
|
|
be, err = backend.New()
|
|
os.Setenv("OLLIE_BACKEND", old) //nolint:errcheck
|
|
} else {
|
|
be, err = backend.New()
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("backend: %w", err)
|
|
}
|
|
|
|
if modelOverride != "" {
|
|
be.SetModel(modelOverride)
|
|
}
|
|
|
|
if err := os.MkdirAll(s.sessionsDir, 0700); err != nil {
|
|
return fmt.Errorf("sessions dir: %w", err)
|
|
}
|
|
|
|
cfgPath := agent.AgentConfigPath(s.agentsDir, agentName)
|
|
cfg, _ := config.Load(cfgPath) // nil cfg is handled by BuildAgentEnv
|
|
|
|
newDisp := tools.NewDispatcherFunc(map[string]func() tools.Server{
|
|
"execute": execute.Decl(cwd),
|
|
})
|
|
|
|
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)
|
|
}
|
|
|
|
// Initialize clean tmpdir for this session.
|
|
sessTemp := "/tmp/ollie/" + sessID
|
|
os.RemoveAll(sessTemp) //nolint:errcheck
|
|
os.MkdirAll(sessTemp, 0700) //nolint:errcheck
|
|
|
|
env := agent.BuildAgentEnv(cfg, newDisp(), cwd)
|
|
|
|
// Inject per-session env vars into the execute server.
|
|
if srv, ok := env.Dispatcher().GetServer("execute"); ok {
|
|
if es, ok := srv.(tools.EnvSetter); ok {
|
|
es.SetEnv("OLLIE_SESSION_ID", sessID)
|
|
}
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
core := agent.NewAgentCore(agent.AgentCoreConfig{
|
|
Backend: be,
|
|
AgentName: agentName,
|
|
AgentsDir: s.agentsDir,
|
|
SessionsDir: s.sessionsDir,
|
|
SessionID: sessID,
|
|
CWD: cwd,
|
|
Env: env,
|
|
NewDispatcher: newDisp,
|
|
})
|
|
|
|
sess := &session{
|
|
id: sessID,
|
|
core: core,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
mutableVers: map[string]*mutableFile{
|
|
"state": {last: core.State()},
|
|
"backend": {last: core.BackendName()},
|
|
"agent": {last: core.AgentName()},
|
|
"model": {last: core.ModelName()},
|
|
"usage": {last: core.Usage()},
|
|
"cwd": {last: core.CWD()},
|
|
},
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.sessions[sessID] = sess
|
|
s.mu.Unlock()
|
|
|
|
fmt.Fprintf(os.Stderr, "olliesrv: new session %s (backend=%s model=%s agent=%s)\n",
|
|
sessID, core.BackendName(), core.ModelName(), core.AgentName())
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) killSession(id string) {
|
|
s.mu.Lock()
|
|
sess := s.sessions[id]
|
|
delete(s.sessions, id)
|
|
s.mu.Unlock()
|
|
if sess != nil {
|
|
sess.cancel()
|
|
fmt.Fprintf(os.Stderr, "olliesrv: killed session %s\n", id)
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// formatEvent converts an agent Event to bytes for appending to the chat log.
|
|
// Output matches ollie-tui's MakeOutputFn so the chat file looks identical to
|
|
// what appears in the TUI chat pane.
|
|
func formatEvent(ev agent.Event) []byte {
|
|
switch ev.Role {
|
|
case "user":
|
|
return []byte("user: " + ev.Content + "\n")
|
|
case "assistant":
|
|
return []byte(ev.Content)
|
|
case "call":
|
|
args := squashWhitespace(ev.Content)
|
|
if len(args) > 500 {
|
|
args = args[:500] + "..."
|
|
}
|
|
return []byte("-> " + ev.Name + "(" + args + ")\n")
|
|
case "tool":
|
|
return []byte(strings.TrimRight(ev.Content, "\n") + "\n")
|
|
case "retry":
|
|
return []byte("retrying in " + ev.Content + "s...\n")
|
|
case "error":
|
|
return []byte("error: " + ev.Content + "\n")
|
|
case "stalled":
|
|
return []byte("agent stalled\n")
|
|
case "info":
|
|
return []byte(ev.Content)
|
|
case "newline":
|
|
return []byte("\n")
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func squashWhitespace(s string) string {
|
|
return strings.Join(strings.Fields(s), " ")
|
|
}
|
|
|
|
// readDir serializes directory entries for the given path, respecting offset and count.
|
|
func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
|
|
var dirs []plan9.Dir
|
|
|
|
makeDir := func(name, fpath string, isDir bool, mode plan9.Perm) plan9.Dir {
|
|
q := plan9.Qid{Path: qidPath(fpath)}
|
|
if isDir {
|
|
q.Type = QTDir
|
|
}
|
|
return plan9.Dir{Qid: q, Mode: mode, Name: name, Uid: "ollie", Gid: "ollie", Muid: "ollie"}
|
|
}
|
|
|
|
if path == "/" {
|
|
dirs = append(dirs, makeDir("a", "/a", true, plan9.DMDIR|0755))
|
|
dirs = append(dirs, makeDir("backends", "/backends", false, 0444))
|
|
dirs = append(dirs, makeDir("help", "/help", false, 0444))
|
|
dirs = append(dirs, makeDir("m", "/m", true, plan9.DMDIR|0755))
|
|
dirs = append(dirs, makeDir("p", "/p", true, plan9.DMDIR|0755))
|
|
dirs = append(dirs, makeDir("pl", "/pl", true, plan9.DMDIR|0755))
|
|
dirs = append(dirs, makeDir("s", "/s", true, plan9.DMDIR|0555))
|
|
dirs = append(dirs, makeDir("sk", "/sk", true, plan9.DMDIR|0555))
|
|
dirs = append(dirs, makeDir("t", "/t", true, plan9.DMDIR|0777))
|
|
} else if path == "/a" {
|
|
entries, _ := os.ReadDir(s.agentsDir)
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
dirs = append(dirs, makeDir(e.Name(), "/a/"+e.Name(), false, 0666))
|
|
}
|
|
}
|
|
} else if path == "/p" {
|
|
entries, _ := os.ReadDir(s.promptsDir)
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
dirs = append(dirs, makeDir(e.Name(), "/p/"+e.Name(), false, 0666))
|
|
}
|
|
}
|
|
} else if path == "/m" {
|
|
entries, _ := os.ReadDir(s.memDir)
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
d := makeDir(e.Name(), "/m/"+e.Name(), false, 0666)
|
|
if info, err := e.Info(); err == nil {
|
|
d.Atime = uint32(info.ModTime().Unix())
|
|
d.Mtime = uint32(info.ModTime().Unix())
|
|
}
|
|
dirs = append(dirs, d)
|
|
}
|
|
}
|
|
} else if path == "/pl" {
|
|
entries, _ := os.ReadDir(s.planDir)
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
d := makeDir(e.Name(), "/pl/"+e.Name(), false, 0666)
|
|
if info, err := e.Info(); err == nil {
|
|
d.Atime = uint32(info.ModTime().Unix())
|
|
d.Mtime = uint32(info.ModTime().Unix())
|
|
}
|
|
dirs = append(dirs, d)
|
|
}
|
|
}
|
|
} else if path == "/sk" {
|
|
dirs = append(dirs, makeDir("idx", "/sk/idx", false, 0444))
|
|
for _, m := range skills.List() {
|
|
name := m.Name + ".md"
|
|
dirs = append(dirs, makeDir(name, "/sk/"+name, false, 0666))
|
|
}
|
|
} else if path == "/t" {
|
|
dirs = append(dirs, makeDir("idx", "/t/idx", false, 0444))
|
|
entries, _ := os.ReadDir(execute.ToolsPath())
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
dirs = append(dirs, makeDir(e.Name(), "/t/"+e.Name(), false, 0777))
|
|
}
|
|
}
|
|
} else if path == "/s" {
|
|
dirs = append(dirs, makeDir("new", "/s/new", false, 0666))
|
|
dirs = append(dirs, makeDir("idx", "/s/idx", false, 0444))
|
|
s.mu.RLock()
|
|
for id := range s.sessions {
|
|
dirs = append(dirs, makeDir(id, "/s/"+id, true, plan9.DMDIR|0555))
|
|
}
|
|
s.mu.RUnlock()
|
|
} else {
|
|
// Session directory: path is /s/{sessid}
|
|
sessPath := path
|
|
type entry struct {
|
|
name string
|
|
mode plan9.Perm
|
|
}
|
|
files := []entry{
|
|
{"ctl", 0200},
|
|
{"prompt", 0200},
|
|
{"enqueue", 0200},
|
|
{"dequeue", 0444},
|
|
{"chat", 0444},
|
|
{"reply", 0444},
|
|
{"state", 0444},
|
|
{"backend", 0666},
|
|
{"agent", 0666},
|
|
{"model", 0666},
|
|
{"cwd", 0666},
|
|
{"usage", 0444},
|
|
{"ctxsz", 0444},
|
|
{"models", 0444},
|
|
{"mcp", 0444},
|
|
}
|
|
for _, e := range files {
|
|
dirs = append(dirs, makeDir(e.name, sessPath+"/"+e.name, false, e.mode))
|
|
}
|
|
}
|
|
|
|
// Serialize all entries to a byte slice.
|
|
var allData []byte
|
|
for _, d := range dirs {
|
|
b, err := d.Bytes()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
allData = append(allData, b...)
|
|
}
|
|
|
|
if offset >= uint64(len(allData)) {
|
|
return nil
|
|
}
|
|
|
|
// Return complete entries starting at offset, up to count bytes.
|
|
remaining := allData[offset:]
|
|
var result []byte
|
|
for len(remaining) >= 2 {
|
|
// Each serialized Dir starts with a uint16 (little-endian) size of the rest.
|
|
entrySize := int(remaining[0]) | int(remaining[1])<<8
|
|
total := entrySize + 2
|
|
if total > len(remaining) {
|
|
break
|
|
}
|
|
if uint32(len(result)+total) > count {
|
|
break
|
|
}
|
|
result = append(result, remaining[:total]...)
|
|
remaining = remaining[total:]
|
|
}
|
|
return result
|
|
}
|
|
|
|
// makeStat builds a plan9.Dir for a logical path.
|
|
func (s *Server) makeStat(path string) plan9.Dir {
|
|
base := pathBase(path)
|
|
if path == "/" {
|
|
base = "."
|
|
}
|
|
|
|
t := s.pathType(path)
|
|
isDir := t == "dir"
|
|
|
|
qid := plan9.Qid{Path: qidPath(path)}
|
|
var mode plan9.Perm
|
|
if isDir {
|
|
qid.Type = QTDir
|
|
if path == "/t" {
|
|
mode = plan9.DMDIR | 0777
|
|
} else if path == "/a" || path == "/m" || path == "/p" || path == "/pl" {
|
|
mode = plan9.DMDIR | 0755
|
|
} else {
|
|
mode = plan9.DMDIR | 0555
|
|
}
|
|
} else {
|
|
switch base {
|
|
case "ctl", "prompt", "enqueue":
|
|
mode = 0200
|
|
case "chat", "reply", "state", "usage", "ctxsz", "models", "mcp", "dequeue":
|
|
mode = 0444
|
|
case "backend", "agent", "model", "cwd":
|
|
mode = 0666
|
|
default:
|
|
if path == "/backends" || path == "/help" || path == "/s/idx" {
|
|
mode = 0444
|
|
} else if path == "/s/new" {
|
|
mode = 0666
|
|
} else if strings.HasPrefix(path, "/a/") {
|
|
mode = 0666
|
|
} else if strings.HasPrefix(path, "/p/") {
|
|
mode = 0666
|
|
} else if strings.HasPrefix(path, "/m/") {
|
|
mode = 0666
|
|
} else if strings.HasPrefix(path, "/pl/") {
|
|
mode = 0666
|
|
} else if path == "/sk/idx" {
|
|
mode = 0444
|
|
} else if strings.HasPrefix(path, "/sk/") {
|
|
mode = 0666
|
|
} else if path == "/t/idx" {
|
|
mode = 0444
|
|
} else if strings.HasPrefix(path, "/t/") {
|
|
mode = 0777
|
|
} else {
|
|
mode = 0444
|
|
}
|
|
}
|
|
}
|
|
|
|
dir := plan9.Dir{
|
|
Qid: qid,
|
|
Mode: mode,
|
|
Name: base,
|
|
Uid: "ollie",
|
|
Gid: "ollie",
|
|
Muid: "ollie",
|
|
}
|
|
|
|
// For chat/reply and tailable mutable files, report actual size and
|
|
// Qid version so polling tools (tail -f) can detect changes via stat.
|
|
// Path format: /s/{sessid}/{file}
|
|
if strings.HasPrefix(path, "/s/") {
|
|
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
|
|
if len(parts) == 3 && parts[0] == "s" {
|
|
s.mu.RLock()
|
|
sess := s.sessions[parts[1]]
|
|
s.mu.RUnlock()
|
|
if sess != nil {
|
|
switch base {
|
|
case "chat":
|
|
sess.mu.RLock()
|
|
dir.Length = uint64(len(sess.chatLog))
|
|
dir.Qid.Vers = sess.chatVers
|
|
sess.mu.RUnlock()
|
|
case "reply":
|
|
sess.mu.RLock()
|
|
dir.Length = uint64(len(sess.core.Reply()))
|
|
dir.Qid.Vers = sess.replyVers
|
|
sess.mu.RUnlock()
|
|
default:
|
|
sess.mu.RLock()
|
|
mf := sess.mutableVers[base]
|
|
if mf != nil {
|
|
dir.Qid.Vers = mf.vers
|
|
}
|
|
sess.mu.RUnlock()
|
|
if mf != nil && atomic.CompareAndSwapInt32(&mf.truncated, 1, 0) {
|
|
// Report size 0 once so tail -f detects truncation.
|
|
dir.Length = 0
|
|
} else {
|
|
content := s.readFile(path)
|
|
dir.Length = uint64(len(content))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// For memory and plan files, report real size and timestamps from disk.
|
|
if strings.HasPrefix(path, "/m/") {
|
|
if info, err := os.Stat(s.memDir + "/" + base); err == nil {
|
|
dir.Length = uint64(info.Size())
|
|
dir.Atime = uint32(info.ModTime().Unix())
|
|
dir.Mtime = uint32(info.ModTime().Unix())
|
|
}
|
|
}
|
|
if strings.HasPrefix(path, "/pl/") {
|
|
if info, err := os.Stat(s.planDir + "/" + base); err == nil {
|
|
dir.Length = uint64(info.Size())
|
|
dir.Atime = uint32(info.ModTime().Unix())
|
|
dir.Mtime = uint32(info.ModTime().Unix())
|
|
}
|
|
}
|
|
|
|
return dir
|
|
}
|