p9: introduce s/ session directory; use nanosecond session IDs

Sessions are now nested under s/ instead of the root, making it
straightforward to count active sessions and find newly created ones
by offset:

  n=$(ls s/ | wc -l)
  echo "new agent=foo" > ctl
  sid=$(ls s/ | sort | sed -n "$((n+1))p")

Session IDs are now Unix nanosecond timestamps (from ollie's updated
NewSessionID), so ls s/ | sort gives strict creation order. The s/
directory is a stable listing surface; ctl remains at the root for
control operations.

All path parsing updated throughout: pathType, readDir, makeStat,
handleWrite, readChat, readReply, readFile. README updated to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-10 08:51:07 +02:00
parent f97b9d355c
commit d9a4ec3255
2 changed files with 92 additions and 78 deletions

View File

@ -9,17 +9,20 @@ The goal is integration, not self-sufficiency. Rather than providing orchestrati
``` ```
ollie/ ollie/
ctl write: "new [backend=x] [model=x] [agent=x]" | "kill <session-id>" ctl write: "new [backend=x] [model=x] [agent=x]" | "kill <session-id>"
<session-id>/ s/ dir: one entry per active session, sorted by creation time
prompt write: submit a prompt to the agent (clears reply) <session-id>/
chat read: cumulative conversation history prompt write: submit a prompt to the agent (clears reply)
reply read: assistant text from the most recent turn only chat read: cumulative conversation history
state read: current agent state (idle, thinking, calling: <tool>) reply read: assistant text from the most recent turn only
ctl write: stop | interrupt | /<slash-command> state read: current agent state (idle, thinking, calling: <tool>)
backend r/w: active backend name ctl write: stop | interrupt | /<slash-command>
agent r/w: active agent name backend r/w: active backend name
model r/w: active model 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 ## Building
```sh ```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. All options are optional and can be specified in any order. Unrecognised keys are rejected.
Valid keys: `backend`, `model`, `agent`. 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 ### Send a prompt
```sh ```sh
echo "what files are in the current directory?" > ~/mnt/ollie/<session-id>/prompt echo "what files are in the current directory?" > ~/mnt/ollie/s/<session-id>/prompt
``` ```
Writes dispatch asynchronously on close, so the shell returns immediately. The agent runs in the background. 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 ### Read the conversation
```sh ```sh
cat ~/mnt/ollie/<session-id>/chat # full history snapshot cat ~/mnt/ollie/s/<session-id>/chat # full history snapshot
tail -f ~/mnt/ollie/<session-id>/chat # follow output as it arrives tail -f ~/mnt/ollie/s/<session-id>/chat # follow output as it arrives
``` ```
The `chat` file is an append-only log of the full conversation. Format: The `chat` file is an append-only log of the full conversation. Format:
@ -82,17 +85,17 @@ assistant: <response>
### Check agent state ### Check agent state
```sh ```sh
cat ~/mnt/ollie/<session-id>/state cat ~/mnt/ollie/s/<session-id>/state
# idle | thinking | calling: <toolname> # idle | thinking | calling: <toolname>
``` ```
### Control a session ### Control a session
```sh ```sh
echo stop > ~/mnt/ollie/<session-id>/ctl # interrupt the current turn echo stop > ~/mnt/ollie/s/<session-id>/ctl # interrupt the current turn
echo /compact > ~/mnt/ollie/<session-id>/ctl # summarize context echo /compact > ~/mnt/ollie/s/<session-id>/ctl # summarize context
echo /clear > ~/mnt/ollie/<session-id>/ctl # clear session history echo /clear > ~/mnt/ollie/s/<session-id>/ctl # clear session history
echo /model qwen3:8b > ~/mnt/ollie/<session-id>/ctl echo /model qwen3:8b > ~/mnt/ollie/s/<session-id>/ctl
``` ```
`ctl` accepts `stop`/`interrupt` or any `/slash-command` supported by the agent. Arbitrary text is rejected. `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/<session-id>/ctl
### Switch backend, model, or agent ### Switch backend, model, or agent
```sh ```sh
echo ollama > ~/mnt/ollie/<session-id>/backend echo ollama > ~/mnt/ollie/s/<session-id>/backend
echo qwen3:8b > ~/mnt/ollie/<session-id>/model echo qwen3:8b > ~/mnt/ollie/s/<session-id>/model
echo myagent > ~/mnt/ollie/<session-id>/agent echo myagent > ~/mnt/ollie/s/<session-id>/agent
``` ```
### Kill a session ### Kill a session
@ -147,41 +150,44 @@ Three sessions with specialized agent configs run a feedback loop. The reviewer
#!/bin/sh #!/bin/sh
cd ~/mnt/ollie 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=developer" > ctl
echo "new agent=reviewer" > ctl echo "new agent=reviewer" > ctl
echo "new agent=tester" > ctl echo "new agent=tester" > ctl
dev=$(ls -d [0-9]* | sed -n '1p') dev=$(ls s/ | sort | sed -n "$((n+1))p")
rev=$(ls -d [0-9]* | sed -n '2p') rev=$(ls s/ | sort | sed -n "$((n+2))p")
tst=$(ls -d [0-9]* | sed -n '3p') tst=$(ls s/ | sort | sed -n "$((n+3))p")
wait_reply() { 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 while true; do
wait_reply $dev wait_reply $dev
code=$(cat $dev/reply) code=$(cat s/$dev/reply)
# review phase # 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 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 "revise this code based on the feedback below."
echo "--- code ---"; echo "$code" echo "--- code ---"; echo "$code"
echo "--- feedback ---"; cat $rev/reply echo "--- feedback ---"; cat s/$rev/reply
} > $dev/prompt } > s/$dev/prompt
continue continue
fi fi
# test phase # 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 wait_reply $tst
if grep -qi "Approved" $tst/reply; then if grep -qi "Approved" s/$tst/reply; then
echo "done." echo "done."
echo "$code" echo "$code"
break break
@ -189,8 +195,8 @@ while true; do
{ echo "fix the failures reported below." { echo "fix the failures reported below."
echo "--- code ---"; echo "$code" echo "--- code ---"; echo "$code"
echo "--- test report ---"; cat $tst/reply echo "--- test report ---"; cat s/$tst/reply
} > $dev/prompt } > s/$dev/prompt
done done
``` ```
@ -224,7 +230,7 @@ _spawn_lock = threading.Lock()
def session_ids(): 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"): def spawn_session(agent="default"):
with _spawn_lock: with _spawn_lock:
@ -240,7 +246,7 @@ def kill_session(sid):
(BASE / "ctl").write_text(f"kill {sid}\n") (BASE / "ctl").write_text(f"kill {sid}\n")
def wait_reply(sid): def wait_reply(sid):
path = BASE / sid / "reply" path = BASE / "s" / sid / "reply"
while True: while True:
if path.stat().st_size > 0: if path.stat().st_size > 0:
return path.read_text().strip() return path.read_text().strip()
@ -253,7 +259,7 @@ def run_subagent(spec):
sid = spawn_session(agent) sid = spawn_session(agent)
try: try:
prompt = f"{context}\n\n{task}".strip() if context else task 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)} return {"agent": agent, "reply": wait_reply(sid)}
finally: finally:
kill_session(sid) kill_session(sid)
@ -302,17 +308,17 @@ Since agents have access to `execute_code`, a session can write and execute a wo
```sh ```sh
$ echo new > ~/mnt/ollie/ctl $ echo new > ~/mnt/ollie/ctl
$ ls ~/mnt/ollie/ $ ls ~/mnt/ollie/s/
20260410-014002-ba70fc ctl 1744276689123456789-2b986c
$ sid=20260410-014002-ba70fc $ sid=1744276689123456789-2b986c
$ tail -f ~/mnt/ollie/$sid/chat & $ tail -f ~/mnt/ollie/s/$sid/chat &
$ echo "list the go files in /home/lkn/src/ollie" > ~/mnt/ollie/$sid/prompt $ 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 user: list the go files in /home/lkn/src/ollie
assistant: -> execute_code({"code":"find /home/lkn/src/ollie -name '*.go'","language":"bash"}) assistant: -> execute_code({"code":"find /home/lkn/src/ollie -name '*.go'","language":"bash"})
= pkg/agent/core.go = pkg/agent/core.go
pkg/agent/loop.go pkg/agent/loop.go
... ...
assistant: The Go source files are: core.go, loop.go, ... assistant: The Go source files are: core.go, loop.go, ...
$ cat ~/mnt/ollie/$sid/state $ cat ~/mnt/ollie/s/$sid/state
idle idle
``` ```

View File

@ -6,14 +6,17 @@
// ctl (write) create/destroy sessions // ctl (write) create/destroy sessions
// "new [backend] [agent]" creates a session // "new [backend] [agent]" creates a session
// "kill <session-id>" destroys a session // "kill <session-id>" destroys a session
// {session-id}/ // s/ (dir) session directory; one entry per active session,
// ctl (write) session control: compact, clear, interrupt // sorted lexicographically by session ID (= creation
// prompt (write) submit a prompt to the agent // order, since IDs are unix-nanosecond timestamps).
// chat (read) cumulative chat history; grows as conversation // {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 // progresses — use cat for a snapshot, tail -f
// to follow new output. // to follow new output.
// backend (r/w) read/write the active backend name // backend (r/w) read/write the active backend name
// agent (r/w) read/write the active agent name // agent (r/w) read/write the active agent name
package p9 package p9
import ( import (
@ -185,26 +188,27 @@ func (s *Server) pathType(path string) string {
return "dir" return "dir"
} }
trimmed := strings.TrimPrefix(path, "/") trimmed := strings.TrimPrefix(path, "/")
parts := strings.SplitN(trimmed, "/", 2) parts := strings.SplitN(trimmed, "/", 3)
switch len(parts) { switch {
case 1: case len(parts) == 1 && parts[0] == "ctl":
if parts[0] == "ctl" { return "file"
return "file" case len(parts) == 1 && parts[0] == "s":
} return "dir"
case len(parts) == 2 && parts[0] == "s":
s.mu.RLock() s.mu.RLock()
_, ok := s.sessions[parts[0]] _, ok := s.sessions[parts[1]]
s.mu.RUnlock() s.mu.RUnlock()
if ok { if ok {
return "dir" return "dir"
} }
case 2: case len(parts) == 3 && parts[0] == "s":
s.mu.RLock() s.mu.RLock()
_, ok := s.sessions[parts[0]] _, ok := s.sessions[parts[1]]
s.mu.RUnlock() s.mu.RUnlock()
if !ok { if !ok {
return "" return ""
} }
switch parts[1] { switch parts[2] {
case "ctl", "prompt", "chat", "reply", "backend", "agent", "model", "state": case "ctl", "prompt", "chat", "reply", "backend", "agent", "model", "state":
return "file" 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); // 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. // tail -f detects growth via stat and re-reads from its last position.
func (s *Server) readChat(fc *plan9.Fcall, path string) *plan9.Fcall { func (s *Server) readChat(fc *plan9.Fcall, path string) *plan9.Fcall {
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2) parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
if len(parts) != 2 { if len(parts) != 3 || parts[0] != "s" {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0} return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
} }
s.mu.RLock() s.mu.RLock()
sess := s.sessions[parts[0]] sess := s.sessions[parts[1]]
s.mu.RUnlock() s.mu.RUnlock()
if sess == nil { if sess == nil {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0} 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. // readReply serves bytes from the session's reply buffer at the requested offset.
func (s *Server) readReply(fc *plan9.Fcall, path string) *plan9.Fcall { func (s *Server) readReply(fc *plan9.Fcall, path string) *plan9.Fcall {
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2) parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
if len(parts) != 2 { if len(parts) != 3 || parts[0] != "s" {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0} return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
} }
s.mu.RLock() s.mu.RLock()
sess := s.sessions[parts[0]] sess := s.sessions[parts[1]]
s.mu.RUnlock() s.mu.RUnlock()
if sess == nil { if sess == nil {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0} 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. // readFile returns the text content for a readable session file.
func (s *Server) readFile(path string) string { func (s *Server) readFile(path string) string {
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2) parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
if len(parts) != 2 { if len(parts) != 3 || parts[0] != "s" {
return "" return ""
} }
sessID, fileName := parts[0], parts[1] sessID, fileName := parts[1], parts[2]
s.mu.RLock() s.mu.RLock()
sess := s.sessions[sessID] sess := s.sessions[sessID]
@ -501,11 +505,12 @@ func (s *Server) handleWrite(path, input string) {
return return
} }
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2) // Path format: /s/{sessid}/{file}
if len(parts) != 2 { parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
if len(parts) != 3 || parts[0] != "s" {
return return
} }
sessID, fileName := parts[0], parts[1] sessID, fileName := parts[1], parts[2]
s.mu.RLock() s.mu.RLock()
sess := s.sessions[sessID] sess := s.sessions[sessID]
@ -795,13 +800,15 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
if path == "/" { if path == "/" {
dirs = append(dirs, makeDir("ctl", "/ctl", false, 0200)) 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() s.mu.RLock()
for id := range s.sessions { 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() s.mu.RUnlock()
} else { } else {
// Session directory. // Session directory: path is /s/{sessid}
sessPath := path sessPath := path
type entry struct { type entry struct {
name string name string
@ -892,13 +899,14 @@ func (s *Server) makeStat(path string) plan9.Dir {
Muid: "ollie", 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. // polling tools (tail -f) can detect growth via stat.
// Path format: /s/{sessid}/{file}
if base == "chat" || base == "reply" { if base == "chat" || base == "reply" {
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2) parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
if len(parts) == 2 { if len(parts) == 3 && parts[0] == "s" {
s.mu.RLock() s.mu.RLock()
sess := s.sessions[parts[0]] sess := s.sessions[parts[1]]
s.mu.RUnlock() s.mu.RUnlock()
if sess != nil { if sess != nil {
sess.mu.RLock() sess.mu.RLock()