Each lsp_*.meta now lives in tools/lsp/cmd/<name>/ alongside
main.go, rather than in data/tools/. The lsp-tools just target
was updated to install both the binary and its .meta sidecar.
AGENTS.md updated to document the pattern for compiled tools.
With the zero-tools transition complete, the tools/builtin/ package
served no purpose — Builtins() returned nil. Removed:
- tools/builtin/builtins.go and the directory
- builtins field, WithBuiltins, Dispatch from toolsrv.Server
- Handler type from toolsrv (only used by builtins)
- OnPreDispatch/WithOnPreDispatch (dead code)
- Strict field and WithStrict (dead code, never read)
- CallTool simplified — no fallback to Dispatch
- builtin import and WithBuiltins/WithStrict calls from ollie-remote
- tools/builtin from justfile build/test targets
Updated AGENTS.md, ARCHITECTURE.md, README.md, EVOLUTION.md
When a streaming tool completes, emit a final empty-Content event so the
log handler always closes the markdown source fence. In the handler,
emit [[[end]]] directly instead of a blank line, so the GUI block parser
transitions back to BlockStart regardless of what follows.
Add a tool_load tool that writes a tool name to the session's 9P
tools file via ollie-9p, enabling on-demand tool loading.
Reduce autoLoad lists in default.json and theo.json to bare
essentials — everything else is loaded via tool_load.
The panic at fs/lifecycle.go:463 was a nil function pointer: fs.Config.MkdirAll
is a func field, not a method, and the struct literal in cmd/olliesrv/main.go
omitted it, defaulting to nil. When CreateAgent called
rs.cfg.MkdirAll(...), it panicked before ever reaching the autoload loop.
- Add MkdirAll: os.MkdirAll to fs.Config in main.go
- Spawn tool server in Create() so empty session/new sessions are
operational, then reuse it in CreateAgent (else if sess.proc != nil)
- Wrap create agent error with fmt.Errorf for clearer messages
- Add panic stack trace to stderr for debugging
Ollie's extensibility is the opposite of Emacs: instead of building
capabilities on top of an interpreter, the core provides integration
surfaces (9P, shell) and pushes everything else to the surrounding
system. This is the Acme/Plan 9 model.
After Tcreate on a file, the fid is open and should be usable for
read/write without a separate Topen. Set f.entry via root.Open()
after creation so subsequent reads/writes on the same fid work.
Tree.Open() returns an error for directories (is a directory), so
f.entry stays nil after Topen on a dir. The entry == nil guard in
read() was firing before the isDir branch, breaking directory reads.
Moved the guard after the isDir check — directories use buildDirData
directly and never need entry.
- handle() is gone — the dispatch + panic recovery is inlined into
the per-request goroutine in Start(). Panic recovery is lifecycle,
not protocol handling.
- Serve → Start: accepts a connection and runs the 9P loop
- Shutdown → Kill: stops accepting, cancels sessions, waits for
goroutines to drain.
- 2 methods remain on *Server: Start, Kill. Server is purely lifecycle.
- All 9P protocol handlers are package-level functions.
- attach() is now a function taking (*olog.Logger, *GroupTable, ...)
- open() is now a function taking (*fs.Tree, *GroupTable, ...)
- Both captured in the handler map via closures over s.sink, s.groups
- 3 methods remain on *Server: Serve, handle, Shutdown
- Added entry (fs.File) field to fid struct
- open() stores the result of rootTree.Open() on f.entry after
permission check succeeds
- read() uses f.entry instead of calling root.Open(path[1:]) —
fids survive renames because the File object references the
tree entry by pointer, not by path
- write() uses f.entry for RequestRespMode writes — no longer
needs root parameter at all; it's a pure connState+fid function
- attach: reject if fc.Fid already in use (was silently overwriting)
- walk: reject if fc.Newfid already in use (both clone and full walk)
- open: validate fid path exists; delete stale fid if path is gone
- stat: validate fid path exists; delete stale fid if path is gone
- wstat: after rename, GC all fids on this connection that reference
the old path tree (was previously updating paths — now just deletes)
The server fully owns the fid map. The client proposes fid numbers,
the server validates and accepts or rejects. Stale fids (pointing to
renamed/deleted paths) are garbage collected when detected.
- GroupTable is a standalone type with Add/Remove/IsMember, owns its
own mutex and map. Server now holds groups *GroupTable.
- Removed AddGroup/RemoveGroup/InGroup from *Server (3 fewer methods).
- Permission check in open() now computes effective bits as a mask:
bits := perm & 7 // world bits
if in group { bits |= (perm >> 3) & 7 } // group bits
if is owner { bits |= (perm >> 6) & 7 } // owner bits
if !hasPermBits(mode, bits) → denied
- No boolean accumulator, no tiered if/else, just bitmask OR.
- 6 remaining Server methods (down from 23).
- hasPermBits uses a 4-entry mask array indexed by mode&3 to select
the required permission bits: OREAD→4, OWRITE→2, ORDWR→6, OEXEC→1
- Zero branches, zero error paths — returns bits&mask == mask
- open() uses OR semantics: checks owner, group, world independently;
any tier granting access is sufficient
- checkPerm removed entirely; permission logic is pure boolean math
- checkPerm no longer needs *Server: it takes uid, perm, and inGroup
directly. The caller (open) resolves uid/gid via fileOwnerGroup,
perm via makeStat, and group membership via InGroup.
- checkPerm is now a pure function: (uname, mode, uid, perm, inGroup)
→ returns only on permissions, needs nothing from Server state.
- open is the remaining method that resolves these values.
server.go: 10 remaining Server methods.
Converted walk, create, read, write, stat, clunk, remove, makeStat,
and buildDirData from *Server methods to functions taking *fs.Tree
as first parameter. Exactly the same pattern as fileOwnerGroup.
Also:
- Replaced the switch in handle() with a s.handler map, built once
in New(). All 11 9P message types (Tversion through Tremove) now
dispatch through the map.
- Extracted flush() as a package-level function (was inlined in switch)
- handle() is now: recover + map lookup. No inline cases, no switch.
- server.go: 802 lines, 10 remaining Server methods (down from 23)
- 20 package-level functions handle the actual protocol logic
- readFile was dead code (defined but never called)
- writeFile was a 7-line helper called only once in clunk;
inlined as direct s.rootTree.Open + e.Write
- FileTree type alias now unused after both deletions
fileOwnerGroup only needed s.rootTree, not the full *Server. Making
it a function taking (*fs.Tree, string) removes one more method from
Server, makes the dependency explicit, and makes it unit-testable.
- openEntry (one-liner delegating to rootTree.Open): inlined into
the two call sites in read() and write()
- statPath (one-liner delegating to rootTree.Stat): inlined into
three call sites (fileOwnerGroup, walk, makeStat)
- InterruptAll (one-liner delegating to fs.InterruptAll): deleted,
it was dead code with no callers anywhere
Also cleaned up: removed unused File type alias and unused 'os' import.
server.go: 825 -> 810 lines. Server method count drops by 3, but more
importantly, zero forwarding boilerplate remains.
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.