The old fs/session/ package (14 files) and cmd/olliesrv/elevate_tree.go
(imperative FileTree implementations) are gone. Replaced by a single
declarative EDSL in fs/spec.go that declares the entire 9P namespace.
cmd/olliesrv/server.go simplified from 1430 to 825 lines:
- Deleted pathType() (110 lines) — tree.Stat() now handles all path resolution
- Deleted isSessionFile(), dirMode(), filePerm() — all from tree's info
- Deleted fsRoute/routes/route/routeDir — single rootTree, no route table
- Deleted elevateTree — EDSL handles /elevate/ in spec.go
- Deleted handleWrite(), readDNS(), helpPath() — dead code
- Deleted all 5 per-fid result fields — EDSL Request handlers manage state
- Deleted all path-based intercepts in read()/write() — entry.RequestRespMode()
- Deleted all path-depth hacks in makeStat() — custom Stat() closures in EDSL
- Deleted all content-length compute logic — info.Size() from tree
- Removed imports: agent, backend, elevate, paths, toolsrv, json
New fs/ package:
- spec.go — single EDSL declaration of the entire namespace
- builder.go — BuildTree() validates and compiles spec into *Tree
- tree.go — configurable FileTree implementation
- fsnode.go — Dir(), Leaf(), TemplateDir(), NodeOption constructors
- agentfiles.go / sessionfiles.go / rootfiles.go / elevatefiles.go
— handler implementations wired by the EDSL
- lifecycle.go — Create, Kill, Rename, Shutdown, InterruptAll
- persist.go, procfiles.go
- edsl.md — documentation of the EDSL
Don't set streamingRole='reasoning' when processing reasoning_think
events. Since no [[[reasoning]]] header is written to the log, the
role-transition logic would emit a double [[[end]]] when the next
role (assistant) fires. Also suppress streamingRole for the tool
role's reasoning_think name check.
The closure returned eventRing by value, copying the struct.
e.cond.L was set to &e.mu (the local variable's mutex), but
after the copy evRing.cond.L still pointed to the ORIGINAL e.mu
while evRing.mu was a different mutex. This caused
sync.Cond.Wait to unlock the wrong (already-unlocked) mutex,
panicking every consumer connecting to /eventwait.
Fixed by using *sync.Cond with sync.NewCond, making evRing a
pointer to prevent any copying of the struct with its cond.
sync.Cond.Wait() unlocks the mutex internally during the wait and
re-locks it on return. Using defer Unlock caused a double-unlock
panic when the GUI connected to /eventwait.
Also added context.AfterFunc to broadcast the cond on cancellation,
so blocked readers don't hang indefinitely if the connection drops.
BREAKING CHANGE: /session/eventwait is removed. Use /eventwait instead.
The old event system had several problems:
1. eventwait lived inside /session/ (per-session) but events are global
2. It returned the entire session index on every event, growing unboundedly
3. No structured event data — consumers couldn't tell WHAT happened
New design:
- Global /eventwait at the root of the 9P namespace
- Events are structured delta lines: '<scope> <action> <path>'
e.g. 'S new session/foo', 'A kill session/foo/agent/bar'
- Ring buffer capped at 100 events (bounded memory)
- Offset-based blocking reads (consumers track position)
- Per-event granularity: S=new/kill/rename, A=new/kill/rename
Call sites updated:
- Session create → 'S new session/{name}'
- Agent create → 'A new session/{name}/agent/{aid}'
- Session kill → 'A kill ...' per agent + 'S kill session/{name}'
- Session rename → 'S rename session/{old} session/{new}'
- Agent rename → 'A rename session/{name}/agent/{old} ...'
KDE GUI updated to use ollie-9p read /eventwait instead of
plan9port 9p read session/eventwait.
The rootOpen function in root.go had a bug where paths like
'session/<id>/agent/<aid>/state' were split into 4 parts by
strings.SplitN(name, "/", 4). An if len(parts) == 4 guard
incorrectly returned 'state: is a directory', turning every agent
file into a directory error.
Removed the erroneous guard so the code falls through to
NewAgentTree(...).Open(parts[3]), which correctly opens the agent's
synthetic file.
statewait is a blocking wait file with a 5-second timeout, not an
indefinite long-block stream like chat. Remove LongBlock=true so the
9P server uses the normal blocking path (WithTimeout 5s) instead of
WithCancel-only behavior.
Also remove temporary statewait debug instrumentation.
Session.WaitChange delegates to a.Agent() (the active agent),
so statewait was blocking on the WRONG agent's state changes.
Agent.WaitChange uses per-agent changeCond, so it properly
blocks until this specific agent's state actually changes.
agentHelper (serving a specific agent's 9P directory) was using
h.sess.Core.Agent() everywhere — which looks up by activeAgentName.
For multi-agent, this is wrong: reading session/{sess}/agent/{aid}/state
should return the state of that specific agent, not the active one.
Fix:
- Added ag *agent.Agent field to agentHelper
- NewAgentTree now takes *agent.Agent parameter
- All 33 references to h.sess.Core.Agent() changed to h.ag
- Updated all callers in root.go to pass the resolved agent
- Agent rename handler uses FindAgent instead of active agent
Without LongBlock, statewait is treated as one-shot: first read
returns current state, second read returns EOF. The GUI had to
restart the connection in a tight loop to keep polling.
With LongBlock=true, the 9P server blocks the read indefinitely
until the agent's state actually changes. The per-fid waitBase
tracks the previous state value so the next read blocks until a
real change. The GUI opens statewait once and reads from it
continuously — no reconnect loop needed.
- Session.Agent() now looks up activeAgentName in the agents slice
instead of returning agents[0]. Falls back to first agent if unset.
- Added SetActiveAgent(), FindAgent(), SaveFuncs() methods.
- New() and AddAgent() set activeAgentName automatically.
- CreateAgent: refactored to ADD to existing core session instead
of overwriting it. If sess.Core exists, builds the new agent via
agent.NewAgent() and adds it via Core.AddAgent().
- agent/new now lists all agents in the session directory.
- 9P path handlers (rootReaddir/Stat/Open) use FindAgent() and
AgentLogFor() to resolve agents by name/ID instead of using
the first agent exclusively.
- Added AgentLogFor(id) to fs Session for per-agent log lookup.
persistSession: check sess.Core.Agent() != nil before saving.
silently skip sessions with no agent instead of logging
'no active session' error on shutdown.
SaveSession: return nil (no-op) when there are no agents,
rather than returning an error that gets logged.
SetAgentLog acquires sess.mu, but CreateAgent in create.go
already holds sess.mu when calling it — causing a deadlock on
agent/new writes. Split into SetAgentLog (locks) and
setAgentLogLocked (no lock) so callers that already hold the
lock can use the locked variant directly.
The rdwr pattern needs per-fid state to work correctly (like session/new).
- Added agentNewResult field to fid struct
- Intercept writes to /session/{id}/agent/new and store result on fid
- Intercept reads from /session/{id}/agent/new and return stored result
- Added CreateAgentFromRoot public API
- Clearer error message when cwd is missing
pathType in server.go blocked walks to agent/new when Core is nil.
Added early return for parts[2]=="new" before the nil-guard so the
agent creation file is always accessible.
Also made agent/new rdwr return the agent name instead of just "ok".
Create() now registers a Session with Core=nil instead of just reserving
a name. All code paths that accessed sess.Core are nil-guarded:
- root.go: buildIndex, InterruptAll, rootOpen (proc read), rootDel (proc dismiss)
- persist.go: Kill, Rename, Shutdown, waitIdle, persistSession
- files.go: handleCtl save command
- create.go: Create() creates and registers the empty session
Also fixed pathType in server.go which panicked on empty sessions
(calling sess.Core.Agent().Name() when Core is nil). The panic was
caught by the recovery handler and returned as 'internal error'.
AGENTS.md: document cmd/ollie-remote test/build quirk
reasoning_think emits with Role:tool producing [[[tool:reasoning_think]]]...[[[end]]]
blocks. When followed by the model's text response ([[[text]]]...[[[end]]]),
the chat log gets two [[[end]]] markers per turn, breaking the state machine
parser.
Fix: check for ev.Name == "reasoning_think" in the tool case and suppress
it the same way as the reasoning role — set streamingRole and return without
writing to the log.
Replace the single-bracket INI-like chat log format with triple-bracket
blocks. Each block opens with [[[type]]] and closes with [[[end]]].
The parser tracks m_inBlock to ignore content between blocks, making
the format robust against accidental header-like content.
- FormatEvent: writes [[[type]]]...content...[[[end]]]
- NewEventHandler: emits [[[end]]] on role transitions
- replayMessagesToLog: uses new format
- C++ parser: updated regex, new [[[end]]] handler, m_inBlock gate
- QML: update hardcoded [info] string
Reasoning text can contain header-like patterns (e.g. [tool:read],
[error]) that the chat log parser interprets as new block boundaries,
causing subsequent text to appear in wrong blocks.
Split the combined assistant/reasoning case: reasoning still tracks
streamingRole for proper separator logic but never calls AppendLog.
The LLM context still receives reasoning — only the chat log omits it.
Bug: reading agent files (log, chat, state, etc.) via 9P returned the
same data regardless of which session or agent ID was specified. The
server did not validate agent IDs and had out-of-bounds slice access
on when the path had fewer components than expected.
Fixes:
- fs/session/root.go: Added guard before all
checks in rootReaddir, rootStat, rootOpen.
Added guard before access in rootStat.
Added agent ID validation — returns error if the requested agent ID
doesn't match the session's actual agent.
- cmd/olliesrv/server.go: Restructured pathType() to nest agent path
checks inside a single
block, preventing proc-path checks from incorrectly matching agent
paths. Added agent ID validation (returns "" = not found).
Added agent-path support in makeStat() for Qid version tracking on
log/chat. Skip blocking files (chat, statewait) in stat-size
computation to prevent hangs.
Delete dbus/dbus.go — the entire D-Bus SessionManager service is gone.
No more 150ms poll loop, no more signal emission, no more method handlers.
- Remove ollie/dbus import, nodbus flag, dbusAdapter variable
- Remove OnSessionCreated/Killed/Renamed hooks from Config
- Remove EnableDBus from Config
- Elevation notifications connect to session bus directly (godbus stays
as a dependency solely for org.freedesktop.Notifications)
All clients now use 9P exclusively. D-Bus is dead.
- Rename 'chat' to 'log': returns last 64KB sliding window (static read).
Tail still works via Qid.Vers. Full history persisted to disk per turn.
- Add 'chat' streaming file: blocking read that delivers new output as
the agent produces it. Per-fid offset tracked via waitBase. Blocks
indefinitely between turns (no EOF). EOF only on kill/session death.
- Add LongBlock interface to File: streaming files bypass the 5s read
timeout. Server returns content directly (ignores Tread offset for
streaming files since waitBase tracks position).
- Add 'kill'/'.'' ctl aliases with FIXME: currently kills entire session.
When multi-agent-per-session lands, kill should kill agent only.
- Rewrite acme frontend (cmd/Ollie) to use 9P client directly instead
of FUSE mount. streamChat() is a blocking read loop — no polling.
All file operations (read, write, ls) go through plan9/client.
- Add 9P streaming prototype in experiments/9p-stream demonstrating
the blocking-read pattern for token delivery.
The key insight: 9P's request-response model gives natural streaming.
Server holds the Tread until data arrives, client blocks on Read().
No polling, no signals, no offset tracking needed.
Execution is now identical for local and remote:
- Session calls Spawn()/SpawnRemote() → owns *Process with socket
- Clients call Dial(LocalAddr(socket)) → *Conn over Unix socket
- ollie-remote serves --listen mode with multi-client accept loop
- Builtins (shell, tool_load, skill_load) run in subprocess with registries
Lifecycle:
- Pdeathsig ensures children die with parent (no orphans)
- Cleanup: SIGTERM → 3s grace → SIGKILL (guaranteed)
- Socket paths use atomic counter for uniqueness
- Session Kill() closes Process
Protocol:
- Extended RPC: set_env, set_cwd, detach, list_detached, etc.
- Shell goes through builtin dispatch (parses 'cmd' field correctly)
- Streaming output via JSON-RPC notifications
- Skip cwd existence check when remote is specified (cwd is on remote)
- Add BatchMode=yes to SSH: pubkey-only, no password fallback
- Add StrictHostKeyChecking=accept-new: auto-accept new hosts, reject
changed keys
- Deploy failure already propagates as session creation error via
RemoteDial returning error
When a session is created with a cwd that doesn't exist, all tool
execution fails with a confusing 'fork/exec landrun: no such file or
directory' because exec.Command sets cmd.Dir to the non-existent path.
Validate that cwd exists and is a directory at session creation time,
returning a clear error instead of allowing a broken session.
Also includes the earlier USER/NAMESPACE env export fix (agent.go,
shell.go) for the ollie-9p namespace path issue.