diff --git a/README.md b/README.md index a4112ca..2223b82 100644 --- a/README.md +++ b/README.md @@ -9,17 +9,20 @@ The goal is integration, not self-sufficiency. Rather than providing orchestrati ``` ollie/ ctl write: "new [backend=x] [model=x] [agent=x]" | "kill " - / - prompt write: submit a prompt to the agent (clears reply) - chat read: cumulative conversation history - reply read: assistant text from the most recent turn only - state read: current agent state (idle, thinking, calling: ) - ctl write: stop | interrupt | / - backend r/w: active backend name - agent r/w: active agent name - model r/w: active model name + s/ dir: one entry per active session, sorted by creation time + / + prompt write: submit a prompt to the agent (clears reply) + chat read: cumulative conversation history + reply read: assistant text from the most recent turn only + state read: current agent state (idle, thinking, calling: ) + ctl write: stop | interrupt | / + backend r/w: active backend name + agent r/w: active agent name + model r/w: active model name ``` +Session IDs are Unix nanosecond timestamps with a random suffix (e.g. `1744276689123456789-2b986c`), so `ls s/` sorted lexicographically gives creation order. + ## Building ```sh @@ -53,12 +56,12 @@ echo "new backend=ollama model=qwen3:8b agent=myagent" > ~/mnt/ollie/ctl All options are optional and can be specified in any order. Unrecognised keys are rejected. Valid keys: `backend`, `model`, `agent`. -A new session directory appears under the mount point named by timestamp + random suffix (e.g. `20260410-014002-ba70fc`). +A new session directory appears under `s/`, named by Unix nanosecond timestamp + random suffix (e.g. `1744276689123456789-2b986c`). ### Send a prompt ```sh -echo "what files are in the current directory?" > ~/mnt/ollie//prompt +echo "what files are in the current directory?" > ~/mnt/ollie/s//prompt ``` Writes dispatch asynchronously on close, so the shell returns immediately. The agent runs in the background. @@ -66,8 +69,8 @@ Writes dispatch asynchronously on close, so the shell returns immediately. The a ### Read the conversation ```sh -cat ~/mnt/ollie//chat # full history snapshot -tail -f ~/mnt/ollie//chat # follow output as it arrives +cat ~/mnt/ollie/s//chat # full history snapshot +tail -f ~/mnt/ollie/s//chat # follow output as it arrives ``` The `chat` file is an append-only log of the full conversation. Format: @@ -82,17 +85,17 @@ assistant: ### Check agent state ```sh -cat ~/mnt/ollie//state +cat ~/mnt/ollie/s//state # idle | thinking | calling: ``` ### Control a session ```sh -echo stop > ~/mnt/ollie//ctl # interrupt the current turn -echo /compact > ~/mnt/ollie//ctl # summarize context -echo /clear > ~/mnt/ollie//ctl # clear session history -echo /model qwen3:8b > ~/mnt/ollie//ctl +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 /model qwen3:8b > ~/mnt/ollie/s//ctl ``` `ctl` accepts `stop`/`interrupt` or any `/slash-command` supported by the agent. Arbitrary text is rejected. @@ -100,9 +103,9 @@ echo /model qwen3:8b > ~/mnt/ollie//ctl ### Switch backend, model, or agent ```sh -echo ollama > ~/mnt/ollie//backend -echo qwen3:8b > ~/mnt/ollie//model -echo myagent > ~/mnt/ollie//agent +echo ollama > ~/mnt/ollie/s//backend +echo qwen3:8b > ~/mnt/ollie/s//model +echo myagent > ~/mnt/ollie/s//agent ``` ### Kill a session @@ -147,41 +150,44 @@ Three sessions with specialized agent configs run a feedback loop. The reviewer #!/bin/sh cd ~/mnt/ollie +# Count existing sessions so we can identify the newly created ones by offset. +n=$(ls s/ | wc -l) + echo "new agent=developer" > ctl echo "new agent=reviewer" > ctl echo "new agent=tester" > ctl -dev=$(ls -d [0-9]* | sed -n '1p') -rev=$(ls -d [0-9]* | sed -n '2p') -tst=$(ls -d [0-9]* | sed -n '3p') +dev=$(ls s/ | sort | sed -n "$((n+1))p") +rev=$(ls s/ | sort | sed -n "$((n+2))p") +tst=$(ls s/ | sort | sed -n "$((n+3))p") wait_reply() { - while [ "$(wc -c < $1/reply)" -eq 0 ]; do sleep 1; done + while [ "$(wc -c < s/$1/reply)" -eq 0 ]; do sleep 1; done } -echo "implement a function that parses a JSON config file" > $dev/prompt +echo "implement a function that parses a JSON config file" > s/$dev/prompt while true; do wait_reply $dev - code=$(cat $dev/reply) + code=$(cat s/$dev/reply) # review phase - printf "Review the following code. End your response with LGTM if it is ready, or PTAL if it needs revision.\n\n%s" "$code" > $rev/prompt + printf "Review the following code. End your response with LGTM if it is ready, or PTAL if it needs revision.\n\n%s" "$code" > s/$rev/prompt wait_reply $rev - if ! grep -qi "LGTM" $rev/reply; then + if ! grep -qi "LGTM" s/$rev/reply; then { echo "revise this code based on the feedback below." echo "--- code ---"; echo "$code" - echo "--- feedback ---"; cat $rev/reply - } > $dev/prompt + echo "--- feedback ---"; cat s/$rev/reply + } > s/$dev/prompt continue fi # test phase - printf "Test the following code. End your response with Approved if all tests pass, or Rejected if they do not.\n\n%s" "$code" > $tst/prompt + printf "Test the following code. End your response with Approved if all tests pass, or Rejected if they do not.\n\n%s" "$code" > s/$tst/prompt wait_reply $tst - if grep -qi "Approved" $tst/reply; then + if grep -qi "Approved" s/$tst/reply; then echo "done." echo "$code" break @@ -189,8 +195,8 @@ while true; do { echo "fix the failures reported below." echo "--- code ---"; echo "$code" - echo "--- test report ---"; cat $tst/reply - } > $dev/prompt + echo "--- test report ---"; cat s/$tst/reply + } > s/$dev/prompt done ``` @@ -224,7 +230,7 @@ _spawn_lock = threading.Lock() def session_ids(): - return {e.name for e in BASE.iterdir() if e.is_dir()} + return {e.name for e in (BASE / "s").iterdir() if e.is_dir()} def spawn_session(agent="default"): with _spawn_lock: @@ -240,7 +246,7 @@ def kill_session(sid): (BASE / "ctl").write_text(f"kill {sid}\n") def wait_reply(sid): - path = BASE / sid / "reply" + path = BASE / "s" / sid / "reply" while True: if path.stat().st_size > 0: return path.read_text().strip() @@ -253,7 +259,7 @@ def run_subagent(spec): sid = spawn_session(agent) try: prompt = f"{context}\n\n{task}".strip() if context else task - (BASE / sid / "prompt").write_text(prompt) + (BASE / "s" / sid / "prompt").write_text(prompt) return {"agent": agent, "reply": wait_reply(sid)} finally: kill_session(sid) @@ -302,17 +308,17 @@ Since agents have access to `execute_code`, a session can write and execute a wo ```sh $ echo new > ~/mnt/ollie/ctl -$ ls ~/mnt/ollie/ -20260410-014002-ba70fc ctl -$ sid=20260410-014002-ba70fc -$ tail -f ~/mnt/ollie/$sid/chat & -$ echo "list the go files in /home/lkn/src/ollie" > ~/mnt/ollie/$sid/prompt +$ ls ~/mnt/ollie/s/ +1744276689123456789-2b986c +$ sid=1744276689123456789-2b986c +$ tail -f ~/mnt/ollie/s/$sid/chat & +$ echo "list the go files in /home/lkn/src/ollie" > ~/mnt/ollie/s/$sid/prompt user: list the go files in /home/lkn/src/ollie assistant: -> execute_code({"code":"find /home/lkn/src/ollie -name '*.go'","language":"bash"}) = pkg/agent/core.go pkg/agent/loop.go ... assistant: The Go source files are: core.go, loop.go, ... -$ cat ~/mnt/ollie/$sid/state +$ cat ~/mnt/ollie/s/$sid/state idle ``` diff --git a/internal/p9/server.go b/internal/p9/server.go index 5533d0d..377310f 100644 --- a/internal/p9/server.go +++ b/internal/p9/server.go @@ -6,14 +6,17 @@ // ctl (write) create/destroy sessions // "new [backend] [agent]" creates a session // "kill " destroys a session -// {session-id}/ -// ctl (write) session control: compact, clear, interrupt -// prompt (write) submit a prompt to the agent -// chat (read) cumulative chat history; grows as conversation +// s/ (dir) session directory; one entry per active session, +// sorted lexicographically by session ID (= creation +// order, since IDs are unix-nanosecond timestamps). +// {session-id}/ +// ctl (write) session control: compact, clear, interrupt +// prompt (write) submit a prompt to the agent +// chat (read) cumulative chat history; grows as conversation // progresses — use cat for a snapshot, tail -f // to follow new output. -// backend (r/w) read/write the active backend name -// agent (r/w) read/write the active agent name +// backend (r/w) read/write the active backend name +// agent (r/w) read/write the active agent name package p9 import ( @@ -185,26 +188,27 @@ func (s *Server) pathType(path string) string { return "dir" } trimmed := strings.TrimPrefix(path, "/") - parts := strings.SplitN(trimmed, "/", 2) - switch len(parts) { - case 1: - if parts[0] == "ctl" { - return "file" - } + parts := strings.SplitN(trimmed, "/", 3) + switch { + case len(parts) == 1 && parts[0] == "ctl": + return "file" + case len(parts) == 1 && parts[0] == "s": + return "dir" + case len(parts) == 2 && parts[0] == "s": s.mu.RLock() - _, ok := s.sessions[parts[0]] + _, ok := s.sessions[parts[1]] s.mu.RUnlock() if ok { return "dir" } - case 2: + case len(parts) == 3 && parts[0] == "s": s.mu.RLock() - _, ok := s.sessions[parts[0]] + _, ok := s.sessions[parts[1]] s.mu.RUnlock() if !ok { return "" } - switch parts[1] { + switch parts[2] { case "ctl", "prompt", "chat", "reply", "backend", "agent", "model", "state": return "file" } @@ -353,12 +357,12 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall) *plan9.Fcall { // 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, "/"), "/", 2) - if len(parts) != 2 { + 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[0]] + sess := s.sessions[parts[1]] s.mu.RUnlock() if sess == nil { return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0} @@ -382,12 +386,12 @@ func (s *Server) readChat(fc *plan9.Fcall, path string) *plan9.Fcall { // 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, "/"), "/", 2) - if len(parts) != 2 { + 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[0]] + sess := s.sessions[parts[1]] s.mu.RUnlock() if sess == nil { return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0} @@ -411,11 +415,11 @@ func (s *Server) readReply(fc *plan9.Fcall, path string) *plan9.Fcall { // readFile returns the text content for a readable session file. func (s *Server) readFile(path string) string { - parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2) - if len(parts) != 2 { + parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3) + if len(parts) != 3 || parts[0] != "s" { return "" } - sessID, fileName := parts[0], parts[1] + sessID, fileName := parts[1], parts[2] s.mu.RLock() sess := s.sessions[sessID] @@ -501,11 +505,12 @@ func (s *Server) handleWrite(path, input string) { return } - parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2) - if len(parts) != 2 { + // Path format: /s/{sessid}/{file} + parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3) + if len(parts) != 3 || parts[0] != "s" { return } - sessID, fileName := parts[0], parts[1] + sessID, fileName := parts[1], parts[2] s.mu.RLock() sess := s.sessions[sessID] @@ -795,13 +800,15 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte { if path == "/" { dirs = append(dirs, makeDir("ctl", "/ctl", false, 0200)) + dirs = append(dirs, makeDir("s", "/s", true, plan9.DMDIR|0555)) + } else if path == "/s" { s.mu.RLock() for id := range s.sessions { - dirs = append(dirs, makeDir(id, "/"+id, true, plan9.DMDIR|0555)) + dirs = append(dirs, makeDir(id, "/s/"+id, true, plan9.DMDIR|0555)) } s.mu.RUnlock() } else { - // Session directory. + // Session directory: path is /s/{sessid} sessPath := path type entry struct { name string @@ -892,13 +899,14 @@ func (s *Server) makeStat(path string) plan9.Dir { Muid: "ollie", } - // For chat files, report the actual log size and Qid version so that + // For chat/reply files, report actual size and Qid version so that // polling tools (tail -f) can detect growth via stat. + // Path format: /s/{sessid}/{file} if base == "chat" || base == "reply" { - parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2) - if len(parts) == 2 { + parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3) + if len(parts) == 3 && parts[0] == "s" { s.mu.RLock() - sess := s.sessions[parts[0]] + sess := s.sessions[parts[1]] s.mu.RUnlock() if sess != nil { sess.mu.RLock()