remove httpgw and webui; update AGENTS.md to reflect current structure

- Remove cmd/ollie-httpgw/ directory and webui/ submodule
- Update justfile: remove httpgw/webui build targets
- Update README.md: remove httpgw/webui references
- Update doc/ARCHITECTURE.md: remove httpgw/webui references
- Update doc/EVOLUTION.md: remove httpgw/webui references
- Update doc/USAGE.md: remove httpgw/webui sections
- Update AGENTS.md: fix repo layout, build targets, testing, architecture
  paths, key files, adding tools/prompts, submodule workflow
This commit is contained in:
Ollie Agent 2026-07-30 20:19:32 +02:00
parent dd5701b1bb
commit ea3db9eb08
14 changed files with 80 additions and 1759 deletions

3
.gitmodules vendored
View File

@ -1,6 +1,3 @@
[submodule "webui"]
path = webui
url = ../ollie-webui.git
[submodule "kde"]
path = kde
url = ../ollie-kde.git

11
.plan.md Normal file
View File

@ -0,0 +1,11 @@
# Plan: Remove httpgw + webui
1. Remove cmd/ollie-httpgw/ directory ✓ (git rm)
2. Remove webui/ submodule ✓ (git rm)
3. Update justfile ✓
4. Update README.md ✓
5. Update doc/ARCHITECTURE.md ✓
6. Update doc/EVOLUTION.md ✓
7. Update doc/USAGE.md ✓
8. Update AGENTS.md — repo layout, build system, testing, architecture, key files, submodule workflow ← IN PROGRESS
9. Commit + push

167
AGENTS.md
View File

@ -1,27 +1,21 @@
# AGENTS.md
Project-level context for AI agents working in this repository.
## Project Overview
Ollie is an AI agent runtime inspired by Plan 9: agent state and behaviors are exposed as files in a 9P namespace. Orchestration, scheduling, and UIs are external — shell scripts, editors, web apps. The core is minimal; capabilities come from composing scripts.
## Repository Layout
Monorepo with Git submodules. Each submodule has its own Go module (except `el` which is Elisp and `kde` which is C++/Qt).
```
ollie/ ← you are here
├── core/ (Go) Core library: agent loop, backends, tools, config
├── 9p/ (Go) 9P filesystem server (olliesrv)
├── dbus/ (Go) D-Bus session manager daemon (ollied)
├── acme/ (Go) Plan 9 acme editor frontend
├── httpgw/ (Go) HTTP-to-9P gateway
├── webui/ (TypeScript) Preact SPA browser frontend
├── kde/ (C++/Qt6) KDE integration: plasmoid, standalone GUI, Kate plugin, KRunner, tray
├── agent/ (Go) Agent loop, history, hooks, commands
├── toolsrv/ (Go) Tool server, registry, sandboxed execution
├── session/ (Go) Session lifecycle, config, persistence
├── fs/session/ (Go) 9P filesystem tree
├── dbus/ (Go) D-Bus adapter
├── cmd/ (Go) Binaries (olliesrv, ollie-9p, ollie-remote)
├── kde/ (C++/Qt6) KDE integration: plasmoid, GUI, Kate plugin, KRunner, tray
├── el/ (Elisp) Emacs frontend (ellie.el)
├── agents/ Agent config JSONs (loaded at runtime)
├── prompts/ System prompt templates (markdown) ← NOT used; see note below
├── prompts/ System prompt templates (markdown)
├── tools/ Tool scripts (file_read, lsp_*, memory_*, etc.)
├── sandbox/ Landlock sandbox config YAML
├── scripts/ User-facing CLI scripts:
@ -32,162 +26,119 @@ ollie/ ← you are here
├── contrib/ Community scripts
└── experiments/ Trial notes
```
### Canonical source for prompts/tools/skills
Three locations have prompt and tool files. The **source of truth** depends on which component you're editing:
Prompt and tool files live in `contrib/`. The `just install-data` target copies them to `~/.config/ollie/`. **Do not edit `~/.config/ollie/` directly** — changes are overwritten on next install.
| Location | Purpose | Deployed by |
|----------|---------|-------------|
| `9p/prompts/`, `9p/tools/` | Canonical source for the 9P server | `just install-data` |
| `9p/skills/`, `kde/skills/` | Shared `agent-skills` submodule (same repo in both) | `just install-data` |
| `kde/prompts/`, `kde/tools/` | KDE-specific copies (may diverge for KDE-only tools like `gui_*`) | `just install-kde` |
| Root `prompts/`, `tools/` | Top-level copies installed by `just install-data` | `just install-data` |
The `just install-data` target copies `agents/`, `prompts/`, `tools/`, `sandbox/` (and `skills/` from submodules) into `~/.config/ollie/`. **Do not edit `~/.config/ollie/` directly** — changes are overwritten on next install.
When adding a new prompt/tool that should be available to all frontends, add it to both `9p/prompts/` (or `9p/tools/`) and the root `prompts/` (or `tools/`) directory.
| `contrib/prompts/`, `contrib/tools/` | Canonical source for all prompts, tools, and skills | `just install-data` |
| `contrib/skills/` | Domain knowledge modules (markdown) | `just install-data` |
| `kde/` | KDE-specific tool scripts (`gui_*`) | `just install-kde` |
## Build System
```bash
# From monorepo root (pick one):
just # 9P path (recommended): core, 9p, acme, kde, httpgw, webui, emacs
just dbus # D-Bus path (KDE-only): core, ollied, kde, httpgw, webui
# Individual build targets:
just core # core library
just ninep # 9P server
# Build everything:
just
# Individual targets:
just ninep # olliesrv + ollie-9p
just acme # acme frontend
just kde # KDE integration (cmake with ~/.local prefix)
just httpgw # HTTP gateway
just webui # web UI (needs npm)
just dbus-bin # D-Bus daemon (ollied)
just ollie-remote # remote execution binary
# Install targets (run automatically by the top-level paths):
just install-data # agents, prompts, tools, skills → ~/.config/ollie/
just install-scripts # CLI scripts → ~/.config/ollie/scripts/
just install-contrib # contrib scripts → ~/bin/
just install-kde # KDE plugins, desktop file, env → ~/.local/
just install-el # ellie.el → ~/.config/emacs/ellie/
just install-dbus-bin # ollied + autostart → ~/.local/bin/, ~/.config/autostart/
# Test:
just test # run all tests (core + 9p)
just test # run all tests
just test-core # core tests only
just test-9p # 9p tests only
# Lifecycle:
just uninstall # remove all installed files
just clean # remove build artifacts
```
Requires [just](https://github.com/casey/just): `cargo install just`
## Testing
```bash
# Go tests (from any Go submodule):
# All Go tests:
go test ./...
# Core has the most tests:
cd core && go test ./...
# 9P server tests:
cd 9p && go test ./...
# KDE has integration test scripts:
cd kde && ./test-e2e.sh
```
## Language & Conventions
- **Go** (core, 9p, dbus, acme, httpgw): Go 1.25+, standard library preferred, minimal dependencies. Modules are `ollie` (core), `olliesrv` (9p), `ollie-dbus`, `ollie-acme`, `ollie-httpgw`.
- **Go** (root module): Go 1.25+, standard library preferred, minimal dependencies.
- **C++20/Qt6/KF6** (kde): CMake build, dual Qt5/Qt6 support where noted.
- **TypeScript** (webui): Vite + Preact.
- **Elisp** (el): single file `ellie.el`.
- **Tool scripts**: Python 3 or Bash. Must be executable. Header comments declare metadata (`# ollie:parallel read`, `# ollie:tier cold`, `# description:`, `# args:`).
### Code style
- Go: `gofmt`, short variable names, error returns (no panics), table-driven tests.
- Tool scripts: emit structured output (`STATUS=ok`, `STATUS=error`). Image/LSP tools return JSON content blocks.
- Prompts: markdown, concise, example-driven. Follow the pattern in existing `tools-*.md` files.
## Architecture (key concepts)
1. **Two integration surfaces**: 9P filesystem (sessions at `s/{name}/`) and D-Bus (`org.ollie.SessionManager`). Tools, skills, memory on physical filesystem via env vars.
2. **Agent loop** (`core/pkg/agent/`): Streaming LLM call → parse tool calls → dispatch → loop until no more tool calls or max steps.
3. **Tool dispatch** (`core/pkg/tools/`): `shell` is built-in. All others are external scripts resolved from `OLLIE_TOOLS_PATH`.
4. **Sandbox** (`core/internal/sandbox/`): Landlock-based. Config in `sandbox/*.yaml` defines filesystem access per profile.
5. **Backends** (`core/pkg/backend/`): Ollama, OpenAI-compatible, Anthropic, Copilot, Kiro. Selectable per-session.
1. **Two integration surfaces**: 9P filesystem (sessions at `session/{id}/agent/{aid}/`) and D-Bus (`org.ollie.SessionManager`). Tools, skills, memory on physical filesystem via env vars.
2. **Agent loop** (`agent/loop.go`): Streaming LLM call → parse tool calls → dispatch → loop until no more tool calls or max steps.
3. **Tool dispatch** (`toolsrv/`): `shell` is built-in. All others are external scripts resolved from `OLLIE_TOOLS_PATH`.
4. **Sandbox** (`sandbox/`): Landlock-based. Config in `sandbox/*.yaml` defines filesystem access per profile.
5. **Backends** (`backend/`): Ollama, OpenAI-compatible, Anthropic, Copilot, Kiro, Gemini, CodeWhisperer. Selectable per-session.
6. **Prompts assembled at runtime**: Agent JSON `prompt` array runs shell commands (via `x/prime`) that cat markdown files together. No static prompt file is used directly.
7. **D-Bus** (kde/dbus only): `ollied` exposes `org.ollie.SessionManager` for KDE frontends. It's a separate daemon from `olliesrv`.
7. **D-Bus** embedded in `olliesrv`: `org.ollie.SessionManager` exposed by the same binary as the 9P server.
## Key Files
| What | Where |
|------|-------|
| Agent loop | `core/pkg/agent/loop.go` |
| Tool dispatcher | `core/pkg/tools/execute/` |
| Remote execution client | `core/pkg/remote/remote.go` |
| Remote execution server | `remote/main.go` |
| Sandbox enforcement | `core/internal/sandbox/` |
| 9P filesystem | `9p/fs/` |
| Session management | `9p/session/` |
| System prompt template | Embedded in binary (`system_prompt.md`) |
| Agent loop | `agent/loop.go` |
| Tool server | `toolsrv/server.go` |
| Remote execution | `toolsrv/remote.go`, `cmd/ollie-remote/` |
| Sandbox enforcement | `sandbox/` |
| 9P filesystem | `fs/session/` |
| Session management | `session/session.go` |
| System prompt template | Embedded in binary |
| Agent configs | `agents/*.json` |
| D-Bus daemon | `dbus/main.go` |
| D-Bus adapter | `dbus/` |
| KDE D-Bus client | `kde/plasmoid/plugin/olliedbusclient.cpp` |
| Standalone GUI | `kde/gui/` |
| Kate plugin | `kde/kate/` |
## Environment
Config lives in `~/.config/ollie/env`. Key variables:
- `OLLIE_BACKEND` — default backend (ollama, openai, anthropic, copilot, kiro)
- `OLLIE_MODEL` — default model
- `OLLIE_TOOLS_PATH` — where tool scripts live (default: `~/.config/ollie/tools`)
- `OLLIE_MEMORY_PATH` — persistent memory directory
- `OLLIE_ENABLED_BACKENDS` — backends available for routing
## Adding a new tool
1. Create an executable script in `tools/` (and `9p/tools/`, `kde/tools/` if needed)
1. Create an executable script in `contrib/tools/`
2. Add header comments: `# description:`, `# args:`, optionally `# ollie:parallel read` or `# ollie:tier cold`
3. Create a matching `prompts/tools-*.md` documentation file (and in `9p/prompts/`, `kde/prompts/`)
4. Run `just install-data` to install
3. Run `just install-data` to install
## Adding a new prompt
1. Write the markdown file in `9p/prompts/` and root `prompts/`
1. Write the markdown file in `contrib/prompts/`
2. If it should be loaded by default, add a `$OLLIE_CFG_PATH/scripts/x/prime <name>` entry to `agents/default.json`
3. Run `just install-data` to install
## Submodule workflow
The only remaining submodule is `kde/`. For KDE:
```bash
# Update all submodules:
git submodule update --remote --merge
# Work in a submodule (e.g., core):
cd core
# Update KDE submodule:
git submodule update --remote --merge kde
# Work in the KDE submodule:
cd kde
# ... make changes, commit ...
git push
cd ..
git add core
git commit -m "update core submodule"
git add kde
git commit -m "update kde submodule"
```
Each submodule has its own remote at `ssh://lkn@lneely.de:44220/lkn/ollie-{name}.git`.
### Nested submodules
Both `9p/` and `kde/` contain a nested `skills/` submodule pointing to the same repo (`agent-skills.git`). There is no root-level `skills/` directory.
When cloning, use `--recurse-submodules` or run `git submodule update --init --recursive`.
## Environment
The only submodules are `kde/` and `webui/` (now removed). For KDE:
```bash
# Update KDE submodule:
git submodule update --remote --merge kde
# Work in the KDE submodule:
cd kde
# ... make changes, commit ...
git push
cd ..
git add kde
git commit -m "update kde submodule"
```
9p/skills/ → ssh://lkn@lneely.de:44220/lkn/agent-skills.git
kde/skills/ → ssh://lkn@lneely.de:44220/lkn/agent-skills.git (same repo)
```
When cloning, use `--recurse-submodules` or run `git submodule update --init --recursive` to get these.
Each submodule has its own remote at `ssh://lkn@lneely.de:44220/lkn/ollie-{name}.git`.
When cloning, use `--recurse-submodules` or run `git submodule update --init --recursive`.

View File

@ -69,10 +69,8 @@ Single Go module with two Git submodules for frontends.
| `sandbox/` | Go | Landlock sandbox config |
| `cmd/olliesrv/` | Go | The main binary |
| `cmd/ollie-9p/` | Go | 9P client |
| `cmd/ollie-httpgw/` | Go | HTTP-to-9P gateway |
| `cmd/ollie-remote/` | Go | Remote execution binary |
| `kde/` | C++/Qt6 | KDE plasmoid, GUI, Kate plugin, KRunner, tray *(submodule)* |
| `webui/` | TypeScript | Preact SPA browser frontend *(submodule)* |
| `agents/` | JSON | Agent configs |
| `prompts/` | Markdown | System prompt templates |
| `tools/` | Python/Bash | Tool scripts (file_read, lsp_*, memory_*, etc.) |
@ -88,7 +86,6 @@ graph TB
ACME[Plan 9 acme]
EMACS[Emacs<br>ellie.el]
KDE[KDE<br>plasmoid / GUI / Kate<br>KRunner / tray]
WEB[Web UI<br>Preact SPA]
end
subgraph "olliesrv"
@ -115,11 +112,8 @@ graph TB
RTOOL[Remote Tools]
end
HTTPGW[ollie-httpgw<br>HTTP→9P gateway]
TER & ACME & EMACS -->|9P| P9
KDE -->|D-Bus| DBUS
WEB --> HTTPGW --> P9
P9 --> SM
DBUS --> SM

View File

@ -1 +0,0 @@
ollie-httpgw

View File

@ -1,93 +0,0 @@
# ollie-httpgw
HTTP gateway for ollie. Translates REST requests to D-Bus calls against `org.ollie.SessionManager`, allowing any HTTP client to manage sessions without a D-Bus library. Also serves as the backend for the web UI.
## Building
```sh
mk
```
Installs `ollie-httpgw` to `$HOME/bin`.
## Usage
```sh
ollie-httpgw start # start daemon (backgrounds itself)
ollie-httpgw fgstart # start in foreground
ollie-httpgw stop # stop daemon
ollie-httpgw status # check if running
```
The gateway listens on `:8011` by default. Override with `-listen`:
```sh
ollie-httpgw start -listen :9090
```
Requires either `olliesrv` or `ollie-dbus` running (provides the D-Bus service).
## API
All responses are JSON. An OpenAPI spec is served at `GET /openapi.json`.
### Sessions
| Method | Path | Action |
|--------|------|--------|
| `GET` | `/sessions` | List all sessions |
| `POST` | `/sessions` | Create a session (body: `{cwd, backend, model, agent}`) |
| `GET` | `/sessions/{id}` | Get session info |
| `DELETE` | `/sessions/{id}` | Kill a session |
| `PATCH` | `/sessions/{id}` | Rename a session (body: `{name}`) |
### Interaction
| Method | Path | Action |
|--------|------|--------|
| `POST` | `/sessions/{id}/submit` | Submit a prompt (body: `{prompt}`) |
| `POST` | `/sessions/{id}/interrupt` | Interrupt the current turn |
### State queries
| Method | Path | Action |
|--------|------|--------|
| `GET` | `/sessions/{id}/state` | Get session state |
| `GET` | `/sessions/{id}/chat?offset=N` | Get chat text from offset |
| `GET` | `/sessions/{id}/usage` | Get token usage |
| `GET` | `/sessions/{id}/cost` | Get cost |
| `GET` | `/sessions/{id}/config` | Get config |
| `POST` | `/sessions/{id}/config` | Set config (body: `{key, value}`) |
| `GET` | `/sessions/{id}/context` | Get message history (JSONL) |
| `GET` | `/sessions/{id}/models` | List models for session's backend |
### Peers
| Method | Path | Action |
|--------|------|--------|
| `GET` | `/sessions/{id}/peers` | List peers |
| `POST` | `/sessions/{id}/peers` | Add peer (body: `{peer_id}`) |
| `DELETE` | `/sessions/{id}/peers/{peer_id}` | Remove peer |
| `POST` | `/sessions/{id}/peers/{peer_id}/submit` | Submit to peer (body: `{prompt}`) |
### Enumeration
| Method | Path | Action |
|--------|------|--------|
| `GET` | `/backends` | List available backends |
| `GET` | `/agents` | List available agents |
## Example
```sh
# Create a session
curl -X POST http://localhost:8011/sessions \
-d '{"cwd": "/home/user/project", "agent": "default"}'
# Submit a prompt
curl -X POST http://localhost:8011/sessions/<id>/submit \
-d '{"prompt": "list files in cwd"}'
# Poll chat
curl http://localhost:8011/sessions/<id>/chat?offset=0
```

View File

@ -1,662 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"github.com/godbus/dbus/v5"
)
const (
dbusService = "org.ollie.SessionManager"
dbusPath = "/org/ollie/SessionManager"
dbusIface = "org.ollie.SessionManager"
)
func dbusConn() (*dbus.Conn, error) {
return dbus.ConnectSessionBus()
}
func dbusCall(method string, args ...interface{}) (*dbus.Call, error) {
conn, err := dbusConn()
if err != nil {
return nil, fmt.Errorf("dbus connect: %w", err)
}
defer conn.Close()
obj := conn.Object(dbusService, dbusPath)
call := obj.Call(dbusIface+"."+method, 0, args...)
if call.Err != nil {
return nil, call.Err
}
return call, nil
}
// dbusHandler routes HTTP requests to D-Bus method calls.
//
// API:
//
// GET /sessions - ListSessions
// POST /sessions - CreateSession
// DELETE /sessions/{id} - KillSession
// PATCH /sessions/{id} - RenameSession
//
// POST /sessions/{id}/submit - Submit prompt
// POST /sessions/{id}/interrupt - Interrupt
//
// GET /sessions/{id}/state - GetState
// GET /sessions/{id}/chat - GetChat (query: offset)
// GET /sessions/{id}/usage - GetUsage
// GET /sessions/{id}/cost - GetCost
// GET /sessions/{id}/config - GetConfig
// POST /sessions/{id}/config - SetConfig
// GET /sessions/{id}/context - GetContext
//
// GET /backends - ListBackends
// GET /sessions/{id}/models - ListModels
// GET /agents - ListAgents
//
// GET /sessions/{id}/peers - PeerList
// POST /sessions/{id}/peers - PeerAdd
// DELETE /sessions/{id}/peers/{peer_id} - PeerRemove
// POST /sessions/{id}/peers/{peer_id}/submit - PeerSubmit
func dbusHandler(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
p = strings.TrimSuffix(p, "/")
parts := strings.Split(p, "/")
switch {
// GET/POST /sessions
case len(parts) == 1 && parts[0] == "sessions":
switch r.Method {
case http.MethodGet:
dbusListSessions(w)
case http.MethodPost:
dbusCreateSession(w, r)
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
// /sessions/{id}
case len(parts) == 2 && parts[0] == "sessions":
sessionID := parts[1]
switch r.Method {
case http.MethodGet:
dbusGetSession(w, sessionID)
case http.MethodDelete:
dbusKillSession(w, sessionID)
case http.MethodPatch:
dbusRenameSession(w, r, sessionID)
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
// /sessions/{id}/{action}
case len(parts) == 3 && parts[0] == "sessions":
sessionID := parts[1]
action := parts[2]
switch action {
case "submit":
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"})
return
}
dbusSubmit(w, r, sessionID)
case "interrupt":
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"})
return
}
dbusInterrupt(w, sessionID)
case "state":
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
return
}
dbusGetState(w, sessionID)
case "chat":
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
return
}
dbusGetChat(w, r, sessionID)
case "usage":
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
return
}
dbusGetUsage(w, sessionID)
case "cost":
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
return
}
dbusGetCost(w, sessionID)
case "config":
switch r.Method {
case http.MethodGet:
dbusGetConfig(w, sessionID)
case http.MethodPost:
dbusSetConfig(w, r, sessionID)
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET or POST"})
}
case "context":
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
return
}
dbusGetContext(w, sessionID)
case "models":
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
return
}
dbusListModels(w, sessionID)
case "peers":
switch r.Method {
case http.MethodGet:
dbusPeerList(w, sessionID)
case http.MethodPost:
dbusPeerAdd(w, r, sessionID)
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET or POST"})
}
default:
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
}
// /sessions/{id}/peers/{peer_id}
case len(parts) == 4 && parts[0] == "sessions" && parts[2] == "peers":
sessionID := parts[1]
peerID := parts[3]
switch r.Method {
case http.MethodDelete:
dbusPeerRemove(w, sessionID, peerID)
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "DELETE only"})
}
// /sessions/{id}/peers/{peer_id}/submit
case len(parts) == 5 && parts[0] == "sessions" && parts[2] == "peers" && parts[4] == "submit":
sessionID := parts[1]
peerID := parts[3]
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"})
return
}
dbusPeerSubmit(w, r, sessionID, peerID)
// GET /backends
case len(parts) == 1 && parts[0] == "backends":
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
return
}
dbusListBackends(w)
// GET /agents
case len(parts) == 1 && parts[0] == "agents":
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
return
}
dbusListAgents(w)
default:
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
}
}
// --- Sessions ---
func dbusListSessions(w http.ResponseWriter) {
call, err := dbusCall("ListSessions")
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var sessions []string
if err := call.Store(&sessions); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
var result []map[string]string
for _, entry := range sessions {
parts := strings.SplitN(entry, "\t", 4)
if len(parts) >= 4 {
result = append(result, map[string]string{
"id": parts[0],
"state": parts[1],
"model": parts[2],
"agent": parts[3],
})
}
}
if result == nil {
result = []map[string]string{}
}
sort.Slice(result, func(i, j int) bool {
return result[i]["id"] < result[j]["id"]
})
writeJSON(w, http.StatusOK, result)
}
func dbusCreateSession(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
CWD string `json:"cwd"`
Backend string `json:"backend"`
Model string `json:"model"`
Agent string `json:"agent"`
Remote string `json:"remote"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
call, err := dbusCall("CreateSession", req.CWD, req.Backend, req.Model, req.Agent, "", req.Remote)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var sessionID string
if err := call.Store(&sessionID); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if sessionID == "" {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "session creation failed"})
return
}
// Rename if a name was provided
if req.Name != "" {
if call, err := dbusCall("RenameSession", sessionID, req.Name); err == nil {
var success bool
if call.Store(&success) == nil && success {
sessionID = req.Name
}
}
}
writeJSON(w, http.StatusCreated, map[string]string{"id": sessionID})
}
func dbusGetSession(w http.ResponseWriter, sessionID string) {
// Return config as the session detail view
dbusGetConfig(w, sessionID)
}
func dbusKillSession(w http.ResponseWriter, sessionID string) {
call, err := dbusCall("KillSession", sessionID)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var success bool
if err := call.Store(&success); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !success {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "session not found"})
return
}
w.WriteHeader(http.StatusNoContent)
}
func dbusRenameSession(w http.ResponseWriter, r *http.Request, sessionID string) {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
call, err := dbusCall("RenameSession", sessionID, req.Name)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var success bool
if err := call.Store(&success); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !success {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "rename failed"})
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- Interaction ---
func dbusSubmit(w http.ResponseWriter, r *http.Request, sessionID string) {
var req struct {
Prompt string `json:"prompt"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
call, err := dbusCall("Submit", sessionID, req.Prompt)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var success bool
if err := call.Store(&success); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !success {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "session not found"})
return
}
writeJSON(w, http.StatusOK, map[string]bool{"submitted": true})
}
func dbusInterrupt(w http.ResponseWriter, sessionID string) {
call, err := dbusCall("Interrupt", sessionID)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var success bool
if err := call.Store(&success); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !success {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "session not found"})
return
}
writeJSON(w, http.StatusOK, map[string]bool{"interrupted": true})
}
// --- State queries ---
func dbusGetState(w http.ResponseWriter, sessionID string) {
call, err := dbusCall("GetState", sessionID)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var state string
if err := call.Store(&state); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"state": state})
}
func dbusGetChat(w http.ResponseWriter, r *http.Request, sessionID string) {
offsetStr := r.URL.Query().Get("offset")
var offset int64
if offsetStr != "" {
offset, _ = strconv.ParseInt(offsetStr, 10, 64)
}
conn, err := dbusConn()
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
defer conn.Close()
obj := conn.Object(dbusService, dbusPath)
call := obj.Call(dbusIface+".GetChat", 0, sessionID, offset)
if call.Err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": call.Err.Error()})
return
}
var text string
var newOffset int64
if err := call.Store(&text, &newOffset); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Chat-Offset", strconv.FormatInt(newOffset, 10))
w.Write([]byte(text))
}
func dbusGetUsage(w http.ResponseWriter, sessionID string) {
call, err := dbusCall("GetUsage", sessionID)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var usage string
if err := call.Store(&usage); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"usage": usage})
}
func dbusGetCost(w http.ResponseWriter, sessionID string) {
call, err := dbusCall("GetCost", sessionID)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var cost string
if err := call.Store(&cost); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"cost": cost})
}
// --- Config ---
func dbusGetConfig(w http.ResponseWriter, sessionID string) {
call, err := dbusCall("GetConfig", sessionID)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var config string
if err := call.Store(&config); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Parse key=value\n into JSON object
m := make(map[string]string)
for _, line := range strings.Split(strings.TrimRight(config, "\n"), "\n") {
if line == "" {
continue
}
k, v, _ := strings.Cut(line, "=")
m[k] = v
}
writeJSON(w, http.StatusOK, m)
}
func dbusSetConfig(w http.ResponseWriter, r *http.Request, sessionID string) {
var req struct {
Key string `json:"key"`
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
call, err := dbusCall("SetConfig", sessionID, req.Key, req.Value)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var success bool
if err := call.Store(&success); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !success {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "set config failed"})
return
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
// --- Context ---
func dbusGetContext(w http.ResponseWriter, sessionID string) {
call, err := dbusCall("GetContext", sessionID)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var context string
if err := call.Store(&context); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Already JSONL — return as-is with appropriate content type
w.Header().Set("Content-Type", "application/x-ndjson")
w.Write([]byte(context))
}
// --- Backends/Models/Agents ---
func dbusListBackends(w http.ResponseWriter) {
call, err := dbusCall("ListBackends")
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var backends []string
if err := call.Store(&backends); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, backends)
}
func dbusListModels(w http.ResponseWriter, sessionID string) {
call, err := dbusCall("ListModels", sessionID)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var models []string
if err := call.Store(&models); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, models)
}
func dbusListAgents(w http.ResponseWriter) {
call, err := dbusCall("ListAgents")
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var agents []string
if err := call.Store(&agents); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, agents)
}
// --- Peers ---
func dbusPeerList(w http.ResponseWriter, sessionID string) {
call, err := dbusCall("PeerList", sessionID)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var peers []string
if err := call.Store(&peers); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if peers == nil {
peers = []string{}
}
writeJSON(w, http.StatusOK, peers)
}
func dbusPeerAdd(w http.ResponseWriter, r *http.Request, sessionID string) {
var req struct {
PeerID string `json:"peer_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
call, err := dbusCall("PeerAdd", sessionID, req.PeerID)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var success bool
if err := call.Store(&success); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !success {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "peer add failed"})
return
}
writeJSON(w, http.StatusCreated, map[string]bool{"added": true})
}
func dbusPeerRemove(w http.ResponseWriter, sessionID, peerID string) {
call, err := dbusCall("PeerRemove", sessionID, peerID)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var success bool
if err := call.Store(&success); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !success {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "peer not found"})
return
}
w.WriteHeader(http.StatusNoContent)
}
func dbusPeerSubmit(w http.ResponseWriter, r *http.Request, sessionID, peerID string) {
var req struct {
Prompt string `json:"prompt"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
call, err := dbusCall("PeerSubmit", sessionID, peerID, req.Prompt)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
var success bool
if err := call.Store(&success); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !success {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "peer submit failed"})
return
}
writeJSON(w, http.StatusOK, map[string]bool{"submitted": true})
}

View File

@ -1,134 +0,0 @@
// ollie-httpgw - HTTP gateway for ollie session management.
//
// Translates HTTP requests to D-Bus calls against org.ollie.SessionManager.
//
// Usage:
//
// ollie-httpgw [start|fgstart|stop|status]
package main
import (
_ "embed"
"encoding/json"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"syscall"
)
//go:embed openapi.json
var openapiSpec []byte
var listen = flag.String("listen", ":8011", "HTTP listen address")
const pidFile = "ollie-httpgw.pid"
func main() {
args := os.Args[1:]
if len(args) >= 1 {
subcmd := args[0]
switch subcmd {
case "start", "fgstart", "stop", "status":
flag.CommandLine.Parse(args[1:]) //nolint:errcheck
ns := os.Getenv("NAMESPACE")
if ns == "" {
ns = fmt.Sprintf("/tmp/ns.%s.:0", os.Getenv("USER"))
}
pidPath := filepath.Join(ns, pidFile)
switch subcmd {
case "start":
if gwRunning() {
fmt.Println("ollie-httpgw already running")
os.Exit(0)
}
daemonize()
case "fgstart":
if gwRunning() {
fmt.Println("ollie-httpgw already running")
os.Exit(0)
}
runGateway(pidPath)
case "stop":
stopGateway(pidPath)
case "status":
if gwRunning() {
fmt.Println("ollie-httpgw running")
} else {
fmt.Println("ollie-httpgw not running")
os.Exit(1)
}
}
return
}
}
// No subcommand — run in foreground directly (no pid file).
flag.CommandLine.Parse(args) //nolint:errcheck
serve()
}
func gwRunning() bool {
conn, err := net.Dial("tcp", *listen)
if err == nil {
conn.Close()
return true
}
return false
}
func daemonize() {
exe, _ := os.Executable()
args := []string{"fgstart", "-listen", *listen}
cmd := exec.Command(exe, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
fmt.Fprintf(os.Stderr, "failed to start: %v\n", err)
os.Exit(1)
}
fmt.Printf("ollie-httpgw started (pid %d)\n", cmd.Process.Pid)
}
func stopGateway(pidPath string) {
data, err := os.ReadFile(pidPath)
if err != nil {
fmt.Println("ollie-httpgw not running")
return
}
var pid int
fmt.Sscanf(string(data), "%d", &pid)
if pid > 0 {
syscall.Kill(pid, syscall.SIGTERM) //nolint:errcheck
}
os.Remove(pidPath) //nolint:errcheck
fmt.Println("ollie-httpgw stopped")
}
func runGateway(pidPath string) {
os.WriteFile(pidPath, fmt.Appendf(nil, "%d", os.Getpid()), 0644) //nolint:errcheck
serve()
os.Remove(pidPath) //nolint:errcheck
}
func serve() {
http.HandleFunc("/openapi.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write(openapiSpec)
})
http.HandleFunc("/", dbusHandler)
log.Printf("ollie-httpgw listening on %s", *listen)
log.Fatal(http.ListenAndServe(*listen, nil))
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

View File

@ -1,366 +0,0 @@
{
"openapi": "3.0.3",
"info": {
"title": "ollie-httpgw",
"description": "HTTP gateway for ollie session management. Supports D-Bus (default) and 9P backend modes.",
"version": "2.0.0"
},
"servers": [
{ "url": "http://localhost:8011", "description": "Default local gateway" }
],
"paths": {
"/sessions": {
"get": {
"tags": ["dbus"],
"summary": "List sessions",
"description": "Returns all active sessions with their id, state, model, and agent.",
"responses": {
"200": {
"description": "Session list",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": { "$ref": "#/components/schemas/SessionSummary" }
}
}
}
},
"502": { "$ref": "#/components/responses/Error" }
}
},
"post": {
"tags": ["dbus"],
"summary": "Create a new session",
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CreateSessionRequest" }
}
}
},
"responses": {
"201": {
"description": "Session created",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": { "id": { "type": "string" } },
"required": ["id"]
}
}
}
},
"400": { "$ref": "#/components/responses/Error" },
"502": { "$ref": "#/components/responses/Error" }
}
}
},
"/sessions/{id}": {
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"get": {
"tags": ["dbus"],
"summary": "Get session config/detail",
"responses": {
"200": {
"description": "Session config as key-value object",
"content": { "application/json": { "schema": { "type": "object", "additionalProperties": { "type": "string" } } } }
}
}
},
"delete": {
"tags": ["dbus"],
"summary": "Kill session",
"responses": {
"204": { "description": "Session killed" },
"404": { "$ref": "#/components/responses/Error" }
}
},
"patch": {
"tags": ["dbus"],
"summary": "Rename session",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
}
}
}
},
"responses": {
"204": { "description": "Renamed" },
"404": { "$ref": "#/components/responses/Error" }
}
}
},
"/sessions/{id}/submit": {
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"post": {
"tags": ["dbus"],
"summary": "Submit a prompt to the session",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": { "prompt": { "type": "string" } },
"required": ["prompt"]
}
}
}
},
"responses": {
"200": { "description": "Submitted", "content": { "application/json": { "schema": { "type": "object", "properties": { "submitted": { "type": "boolean" } } } } } },
"404": { "$ref": "#/components/responses/Error" }
}
}
},
"/sessions/{id}/interrupt": {
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"post": {
"tags": ["dbus"],
"summary": "Interrupt the running session",
"responses": {
"200": { "description": "Interrupted", "content": { "application/json": { "schema": { "type": "object", "properties": { "interrupted": { "type": "boolean" } } } } } },
"404": { "$ref": "#/components/responses/Error" }
}
}
},
"/sessions/{id}/state": {
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"get": {
"tags": ["dbus"],
"summary": "Get session state",
"responses": {
"200": { "description": "State", "content": { "application/json": { "schema": { "type": "object", "properties": { "state": { "type": "string" } } } } } }
}
}
},
"/sessions/{id}/chat": {
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "offset", "in": "query", "required": false, "schema": { "type": "integer", "format": "int64", "default": 0 }, "description": "Byte offset to resume from" }
],
"get": {
"tags": ["dbus"],
"summary": "Get chat transcript",
"description": "Returns chat text from the given offset. The X-Chat-Offset response header contains the new offset for subsequent requests.",
"responses": {
"200": {
"description": "Chat text",
"headers": {
"X-Chat-Offset": { "schema": { "type": "integer", "format": "int64" }, "description": "New byte offset for next poll" }
},
"content": { "text/plain": { "schema": { "type": "string" } } }
}
}
}
},
"/sessions/{id}/usage": {
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"get": {
"tags": ["dbus"],
"summary": "Get token usage",
"responses": {
"200": { "description": "Usage string", "content": { "application/json": { "schema": { "type": "object", "properties": { "usage": { "type": "string" } } } } } }
}
}
},
"/sessions/{id}/cost": {
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"get": {
"tags": ["dbus"],
"summary": "Get session cost",
"responses": {
"200": { "description": "Cost string", "content": { "application/json": { "schema": { "type": "object", "properties": { "cost": { "type": "string" } } } } } }
}
}
},
"/sessions/{id}/config": {
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"get": {
"tags": ["dbus"],
"summary": "Get session config",
"responses": {
"200": { "description": "Config as key-value", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": { "type": "string" } } } } }
}
},
"post": {
"tags": ["dbus"],
"summary": "Set a config value",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"key": { "type": "string" },
"value": { "type": "string" }
},
"required": ["key", "value"]
}
}
}
},
"responses": {
"200": { "description": "Success", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" } } } } } },
"400": { "$ref": "#/components/responses/Error" }
}
}
},
"/sessions/{id}/context": {
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"get": {
"tags": ["dbus"],
"summary": "Get message context (JSONL)",
"responses": {
"200": { "description": "JSONL messages", "content": { "application/x-ndjson": { "schema": { "type": "string" } } } }
}
}
},
"/sessions/{id}/models": {
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"get": {
"tags": ["dbus"],
"summary": "List models for session's backend",
"responses": {
"200": { "description": "Model list", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } }
}
}
},
"/sessions/{id}/peers": {
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
"get": {
"tags": ["dbus"],
"summary": "List peers",
"responses": {
"200": { "description": "Peer IDs", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } }
}
},
"post": {
"tags": ["dbus"],
"summary": "Add a peer",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": { "peer_id": { "type": "string" } },
"required": ["peer_id"]
}
}
}
},
"responses": {
"201": { "description": "Peer added" },
"400": { "$ref": "#/components/responses/Error" }
}
}
},
"/sessions/{id}/peers/{peer_id}": {
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "peer_id", "in": "path", "required": true, "schema": { "type": "string" } }
],
"delete": {
"tags": ["dbus"],
"summary": "Remove a peer",
"responses": {
"204": { "description": "Peer removed" },
"404": { "$ref": "#/components/responses/Error" }
}
}
},
"/sessions/{id}/peers/{peer_id}/submit": {
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "peer_id", "in": "path", "required": true, "schema": { "type": "string" } }
],
"post": {
"tags": ["dbus"],
"summary": "Submit prompt to a peer session",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": { "prompt": { "type": "string" } },
"required": ["prompt"]
}
}
}
},
"responses": {
"200": { "description": "Submitted" },
"404": { "$ref": "#/components/responses/Error" }
}
}
},
"/backends": {
"get": {
"tags": ["dbus"],
"summary": "List available backends",
"responses": {
"200": { "description": "Backend names", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } }
}
}
},
"/agents": {
"get": {
"tags": ["dbus"],
"summary": "List available agents",
"responses": {
"200": { "description": "Agent names", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } }
}
}
}
},
"components": {
"schemas": {
"SessionSummary": {
"type": "object",
"properties": {
"id": { "type": "string" },
"state": { "type": "string" },
"model": { "type": "string" },
"agent": { "type": "string" }
},
"required": ["id", "state", "model", "agent"]
},
"CreateSessionRequest": {
"type": "object",
"properties": {
"cwd": { "type": "string", "description": "Working directory (defaults to $HOME)" },
"backend": { "type": "string", "description": "Backend name (e.g. ollama, anthropic)" },
"model": { "type": "string", "description": "Model name" },
"agent": { "type": "string", "description": "Agent name (default: 'default')" }
}
},
"Error": {
"type": "object",
"properties": { "error": { "type": "string" } },
"required": ["error"]
}
},
"responses": {
"Error": {
"description": "Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Error" }
}
}
}
}
}
}

View File

@ -1,23 +1,15 @@
# ollie Architecture
## Philosophy
ollie's design philosophy is Emacs: a small, extensible core. The Go runtime (`agent.Agent`) defines what an agent *is* — an event loop, a backend connection, a message history, and three built-in primitives (`shell`, tool registry, skill registry). Everything else — orchestration, scheduling, workflows, UIs — is pushed out to the surrounding environment.
The primary integration philosophy is Plan 9's "everything is a file." `olliesrv` exposes agent state and behaviors as files in a 9P namespace. Any program that can read and write files can drive an agent: shell scripts, editors, web apps, cron, containers.
For desktop-native integration, `olliesrv` embeds a D-Bus adapter (`org.ollie.SessionManager`) alongside its 9P interface. The KDE GUI, plasmoid, and web UI connect this way. For D-Bus-only deployments, `-no9p` disables the 9P listener entirely.
Design principles:
- **Small extensible core.** The agent runtime is minimal; capabilities come from composing external scripts.
- **Three built-in primitives.** `shell`, tool registry (`tool_load`/`tool_list`/`tool_active`), and skill registry (`skill_load`/`skill_list`/`skill_active`) are the only tools compiled into the core. Everything else is a script.
- **Two integration paths.** 9P filesystem (canonical) and/or D-Bus (conventional). Same core, different surfaces, selectable via flags.
- **No framework lock-in.** Frontends are decoupled via whichever interface they prefer; the core doesn't know or care.
## Repository Structure
Single Go module (`ollie`) with two Git submodules for decoupled frontends:
```
ollie/
├── agent/ Agent loop, history, hooks, prompt resolution, commands
@ -36,10 +28,8 @@ ollie/
├── cmd/ Binaries:
│ ├── olliesrv/ 9P server
│ ├── ollie-9p/ 9P client
│ ├── ollie-httpgw/ HTTP-to-9P gateway
│ └── ollie-remote/ Remote execution server
├── kde/ KDE integration (submodule) — plasmoid, GUI, Kate plugin, KRunner, tray
├── webui/ Preact SPA browser frontend (submodule)
├── agents/ Agent config JSONs (default, coding, orchestrator, worker, ...)
├── prompts/ Prompt templates (markdown)
├── tools/ Tool scripts (file_read, lsp_*, memory_*, subagent_*, ...)
@ -48,9 +38,7 @@ ollie/
├── sandbox/ Sandbox config YAML files
└── doc/ Documentation
```
## System Overview
```mermaid
flowchart TB
subgraph Frontends["Frontends"]
@ -58,27 +46,22 @@ flowchart TB
ACME["acme (Plan 9)"]
ELLIE["ellie (Emacs)"]
KDE["KDE GUI / Plasmoid"]
WEB["Web UI"]
end
subgraph Integration["Integration Layer"]
direction LR
P9["9P Filesystem"]
DBUS["org.ollie.SessionManager (D-Bus)"]
end
subgraph Server["olliesrv"]
direction TB
NS["9P Namespace\nsession/ · backends · models"]
DBA["Embedded D-Bus Adapter"]
end
subgraph Core["Agent Engine (per session)"]
LOOP["Agent Loop\n(agent/loop.go)"]
SESS["Session State\n(agent/history.go)"]
TRV["toolsrv.Server\n· shell execution\n· tool registry\n· landlock sandbox"]
end
subgraph Backends["LLM Backends"]
OLLAMA["Ollama"]
OPENAI["OpenAI / OpenRouter"]
@ -86,33 +69,24 @@ flowchart TB
GEMINI["Gemini"]
CW["CodeWhisperer"]
end
TUI --> P9
ACME --> P9
ELLIE --> P9
KDE --> DBUS
WEB --> DBUS
P9 --> NS
DBUS --> DBA
NS --> LOOP
DBA --> LOOP
LOOP <--> SESS
LOOP --> TRV
LOOP <--> Backends
TRV --> Shell["sandboxed shell\n(landrun)"]
TRV --> Tools["tool scripts\n(OLLIE_TOOLS_PATH)"]
```
## Core Library
The core is a single Go module (`ollie`) with no binary. Binaries live in `cmd/`.
### Package Layout
| Package | Purpose |
|---|---|
| `agent/` | Agent struct, loop, history, compaction, hooks, commands, prompt resolution, state |
@ -125,15 +99,12 @@ The core is a single Go module (`ollie`) with no binary. Binaries live in `cmd/`
| `env/` | Session environment helpers |
| `log/` | Structured logging |
| `paths/` | XDG path resolution |
### Key Types
**`agent.Agent`** — the concrete struct frontends drive:
```go
type Agent struct {
// unexported fields — no public interface
}
func New(cfg AgentConfig) *Agent
func (a *Agent) Submit(ctx context.Context, input string)
func (a *Agent) Interrupt(cause error) bool
@ -143,7 +114,6 @@ func (a *Agent) WaitChange(ctx context.Context, field, current string) (string,
func (a *Agent) Close()
// ... model/backend info, CWD, usage, cost, etc.
```
**`backend.Backend`** — LLM provider contract:
```go
type Backend interface {
@ -155,7 +125,6 @@ type Backend interface {
Models(ctx context.Context) []string
}
```
**`toolsrv.Runner`** — minimal tool execution interface:
```go
type Runner interface {
@ -163,21 +132,16 @@ type Runner interface {
CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error)
}
```
**`toolsrv.Server`** — the concrete tool server (implements Runner):
```go
type Server struct { /* ... */ }
```
### Agent Loop (`agent/loop.go`)
The loop is the heart of the system. On each turn:
1. **Stream** — call `backend.ChatStream()` with messages + tool definitions
2. **Dispatch** — if the model returns tool calls, execute them via `toolsrv.Server`
3. **Update** — append assistant message + tool results to session history
4. **Repeat** — loop until the model produces a final text response (no tool calls)
Safety mechanisms:
- **Consecutive error limits**: soft nudge at 5, hard abort at 10
- **Replan gate**: after 8 rounds without a PLAN: block, inject a replan nudge
@ -185,11 +149,8 @@ Safety mechanisms:
- **Max steps**: configurable per-agent; soft exit when reached
- **Transient retry**: up to 3 retries with exponential backoff for rate limits and 5xx
- **Context overflow**: triggers automatic compaction and retry
### Session & Context Management (`agent/history.go`)
`History` owns the message history as a flat `[]backend.Message` slice. Key behaviors:
- **TaskState**: structured JSON overlay (objective, plan_step, constraints, last_action, next_decision) injected at the top of every turn
- **Compaction**: three-zone strategy when context reaches 50% of model limit:
- **Cold zone**: structured task state summary
@ -197,11 +158,8 @@ Safety mechanisms:
- **Hot zone**: last 8 messages verbatim
- **Result tiers**: tool results classified as Hot (verbatim), Warm (summarized on compaction), or Cold (immediately collapsed)
- **Persistence**: sessions saved as JSON to `{sessionsDir}/{id}.json`
### Hooks (`agent/hooks.go`)
Lifecycle callbacks executed as shell commands with JSON payload on stdin:
| Hook | When | Exit codes |
|---|---|---|
| `agentSpawn` | Session creation | 0=ok, 2=block |
@ -212,17 +170,11 @@ Lifecycle callbacks executed as shell commands with JSON payload on stdin:
| `preCompact` | Before compaction | 0=ok |
| `postCompact` | After compaction | 0=ok |
| `turnError` | On backend error | 0=handled (skip retries) |
### Prompt Resolution (`agent/prompt_resolver.go`)
Agent configs declare prompts as a JSON array of shell commands. Each command is executed with `$OLLIE` and `$PWD` available; stdout is concatenated to form the system prompt. This enables dynamic, composable prompts assembled from template fragments.
## 9P Server (`cmd/olliesrv/`)
`olliesrv` is the central daemon. It implements a 9P2000 file server that exposes the entire agent namespace:
### Namespace Layout
```
session/
├── new write key=value to create session
@ -261,24 +213,18 @@ session/
│ ├── tail exec helper
│ └── proc/ detached background processes
```
### 9P Interaction Model
All agent interaction uses the `ollie-9p` client, which auto-discovers the server via `$NAMESPACE` and identifies the caller via `$OLLIE_UNAME`. The 9P filesystem exposes session state, control, and inter-agent communication as files:
- Sessions are directories under `session/`
- Prompts, state, chat history, and config are readable/writable files
- Permission enforcement uses 9P user principals
### Permission Model (`fs/session/perm.go`)
All permissions are declared in a single registry (`Perms`). Key design:
- `prompt` is mode `0666` — group+other can write, owner (the agent) cannot
- `chat` is mode `0444` — read-only for everyone
- `ctl` is mode `0666` — anyone can send control commands
- `plan` is mode `0666` — readable by peer agents
### Session Management (`fs/session/`)
The `*fs.Tree` IS the session collection. Package functions manage lifecycle:
- `NewRoot(cfg)` — create the root tree
- `Lookup(tree, id)` — find a session by ID
@ -287,25 +233,16 @@ The `*fs.Tree` IS the session collection. Package functions manage lifecycle:
- `KillFromRoot(tree, id)` — kill a session
- `RenameFromRoot(tree, old, new)` — rename a session
- `Shutdown(tree)` — clean shutdown
### Embedded D-Bus Adapter
`olliesrv` includes an embedded D-Bus adapter that claims `org.ollie.SessionManager` on the session bus. This provides:
- The same method/signal interface used by KDE frontends
- `SessionCreated`, `SessionKilled`, `StateChanged`, `ChatUpdated` signals
- Allows KDE GUI, plasmoid, and web UI (via `httpgw`) to interact with sessions without speaking 9P
- Lifecycle watchers that bridge session state changes to D-Bus signals
The adapter is best-effort: if no session bus is available (e.g., headless/container), `olliesrv` continues functioning as a pure 9P server.
## D-Bus Adapter (`dbus/`)
The D-Bus adapter is embedded in `olliesrv` (no separate daemon). For D-Bus-only deployments, run `olliesrv -no9p`. This disables the filesystem listener while keeping the full D-Bus interface. Session persistence uses the same `~/.local/share/ollie/sessions/active/` directory.
## KDE Integration (`kde/`)
The KDE submodule provides several Qt/QML components that connect to `org.ollie.SessionManager` over D-Bus:
| Component | Path | Purpose |
|---|---|---|
| GUI | `kde/gui/` | Standalone Qt window for chat interaction |
@ -313,27 +250,18 @@ The KDE submodule provides several Qt/QML components that connect to `org.ollie.
| Kate plugin | `kde/kate/` | In-editor chat pane and ghost completion |
| KRunner | `kde/krunner/` | Quick-launch sessions from KRunner |
| System tray | `kde/tray/` | Tray icon with session status |
All KDE components use `QDBusServiceWatcher` to handle daemon restarts gracefully — recreating the `QDBusInterface` when the service reappears and refreshing the session list.
## Tool System
### Architecture
The tool system has exactly one built-in server (`toolsrv.Server`) that exposes three primitives:
| Primitive | Purpose |
|---|---|
| `shell` | Run a bash command in a sandbox |
| `tool_load`/`tool_list`/`tool_active` | Dynamic tool registry |
| `skill_load`/`skill_list`/`skill_active` | Dynamic skill registry |
All other capabilities are **tool scripts** — executable files discovered from `OLLIE_TOOLS_PATH` (default: `~/.config/ollie/tools/`).
### Tool Scripts
Tool scripts are plain executables with a structured header comment declaring their name, description, parameters, and parallelism annotation:
- `file_read`, `file_write`, `file_edit`, `file_glob`, `file_grep` — filesystem I/O
- `lsp_definition`, `lsp_references`, `lsp_rename`, `lsp_symbols`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion` — LSP bridge (Python)
- `memory_remember`, `memory_recall` — persistent memory
@ -341,13 +269,9 @@ Tool scripts are plain executables with a structured header comment declaring th
- `subagent_spawn`, `subagent_generate` — sub-agent lifecycle
- `route` — orchestrator dispatch
- `gui_*` — KDE desktop automation (screenshot, windows, clipboard, input, ...)
Scripts annotated `ollie:parallel read` can be fanned out concurrently by the agent loop.
### Sandboxing
Every code execution step is wrapped with [landrun](https://github.com/landlock-lsm/landrun) (Landlock LSM). Configuration is YAML-based:
```yaml
general:
best_effort: true
@ -359,13 +283,9 @@ network:
enabled: true
unrestricted: true
```
Template variables (`{CWD}`, `{HOME}`, `{XDG_*}`, `{OLLIE_*}`) are expanded at runtime. **landrun is mandatory** — there is no unsandboxed fallback. Steps marked `elevated: true` bypass the sandbox via the elevation backend (`x/elevate`).
## LLM Backends (`backend/`)
All backends implement the streaming-only `Backend` interface:
| Backend | Wire Format | Notes |
|---|---|---|
| `OllamaBackend` | `/api/chat` | Local models, context length from `/api/show` |
@ -374,63 +294,43 @@ All backends implement the streaming-only `Backend` interface:
| `GeminiBackend` | Gemini API | Google Gemini models |
| `CopilotBackend` | GitHub Copilot API | Copilot Chat |
| `CodeWhispererBackend` | CodeWhisperer API | AWS CodeWhisperer |
Shared infrastructure:
- `streamRequest()` — common HTTP streaming with error classification
- `RateLimitError`, `TransientError`, `ContextOverflowError`, `ToolUnsupportedError` — typed errors for loop control
- `GenerationParams` — unified sampling parameters across all backends
## Multi-Agent System
Multi-agent coordination is built entirely on the filesystem primitive:
### Mechanism
1. **Spawn**: write session spec to `session/new` → new session directory appears
2. **Prompt**: write task to `session/{id}/prompt`
3. **Observe**: read `session/{id}/state` or block on `session/{id}/statewait`
4. **Result**: child writes to `session/{parent_id}/prompt` when done
### Delegation Tools
| Tool | Semantics |
|---|---|
| `route` | Orchestrator dispatch; selects backend+model for a task |
| `subagent_spawn` | Raw session creation with full control |
| `subagent_generate` | JIT agent identity generation |
### Callback Protocol
```
[from={session_id}]
STATUS: done|error
SUMMARY: <result>
ARTIFACTS: <paths>
```
The parent never actively waits — results arrive as queued prompts.
## HTTP Gateway (`cmd/ollie-httpgw/`)
`ollie-httpgw` bridges HTTP clients (like the web UI) to the 9P filesystem. It translates REST-style requests into 9P filesystem operations and streams events back as Server-Sent Events.
This is the secondary integration path for non-LLM consumers (web UIs, dashboards, external tools) that want a conventional HTTP API without speaking 9P directly.
## Frontends
Frontends connect via either 9P (filesystem) or D-Bus, depending on preference:
```mermaid
flowchart LR
subgraph Core["agent.Agent"]
AG["Agent Engine"]
end
subgraph Surfaces["Integration Surfaces"]
P9["9P Filesystem\n(session/ namespace)"]
DB["D-Bus\n(org.ollie.SessionManager)"]
end
subgraph Frontends
SH["s/sh (terminal)"]
ACME["acme (Plan 9)"]
@ -439,41 +339,30 @@ flowchart LR
KP["KDE Plasmoid"]
KK["Kate Plugin"]
KR["KRunner"]
WEB["Web UI"]
HTTP["curl / scripts"]
end
AG --> P9
AG --> DB
SH --> P9
ACME --> P9
EL --> P9
HTTP --> P9
KG --> DB
KP --> DB
KK --> DB
KR --> DB
WEB --> DB
```
Frontends are fully decoupled — the core doesn't know which interface they use.
## Script Namespaces
User-facing scripts installed to `~/.config/ollie/scripts/{ns}/`:
| Namespace | Purpose | Key Scripts |
|---|---|---|
| `s/` | Session management | `sh` (interactive), `b` (one-shot), `bfg` (blocking), `bbg` (background) |
| `u/` | Utility compositions | `complete` (code completion), `optimize` (prompt optimization), `cascade`, `escalate` |
| `x/` | System plugins | `elevate` (privilege escalation), `prime` (prompt template renderer), `freeloader` |
## Agent Configuration
Agent configs are JSON files in `~/.config/ollie/agents/`:
```json
{
"prompt": [
@ -492,11 +381,8 @@ Agent configs are JSON files in `~/.config/ollie/agents/`:
"maxSteps": 50
}
```
The `prompt` array is the key innovation: each element is a shell command whose stdout is concatenated to form the system prompt. This makes prompts composable, dynamic, and environment-aware.
## Data Flow
```mermaid
sequenceDiagram
participant F as Frontend
@ -505,17 +391,14 @@ sequenceDiagram
participant B as LLM Backend
participant T as toolsrv.Server
participant SH as sandbox (landrun)
F->>S: write to session/{id}/prompt
S->>A: Submit()
activate A
loop Agent Loop
A->>B: ChatStream(messages, tools)
activate B
B-->>A: stream events
deactivate B
alt has tool calls
A->>T: Dispatch(tool, args)
activate T
@ -529,14 +412,11 @@ sequenceDiagram
break
end
end
A-->>S: turn complete
deactivate A
S-->>F: readable via session/{id}/chat
```
## Extension Points
| What | How |
|---|---|
| Add an LLM backend | Implement `backend.Backend` |
@ -546,9 +426,7 @@ sequenceDiagram
| Add a skill | Drop a markdown file into `skills/` |
| Add an agent persona | Create a JSON config in `agents/` |
| Customize sandbox | Create a YAML in `~/.config/ollie/sandbox/` |
## Key Design Decisions
1. **Two integration surfaces** — 9P filesystem (canonical, Unix philosophy) and D-Bus (conventional, for GUI/web consumers). Both are thin adapters over the same core.
2. **Three built-in primitives** — `shell`, tool registry, skill registry. Everything else is a script. This keeps the core small and makes all other tools equal citizens.
3. **Mandatory sandboxing** — no unsandboxed fallback. Security is not optional.

View File

@ -1,45 +1,34 @@
# Architectural Evolution
How the Ollie system-of-systems emerged and evolved.
~1300 commits over ~3.5 months (Apr 11 – Jul 30, 2026).
## Timeline
```mermaid
gantt
title Ollie Evolution
dateFormat YYYY-MM-DD
axisFormat %b %d
section Foundation
Monorepo + submodules :done, 2026-04-11, 14d
9P filesystem :done, 2026-04-14, 28d
section Core Primitives
Session lifecycle (session/) :done, 2026-04-20, 30d
Tool scripts :done, 2026-05-01, 20d
Skills system :done, 2026-05-10, 14d
section Execution
execute_code / shell :done, 2026-05-08, 20d
Batch jobs (b/ → session/bfg) :done, 2026-05-15, 25d
Sandbox (landrun) :done, 2026-05-20, 40d
section Multi-Agent
Subagent spawn :done, 2026-05-25, 15d
Cascade orchestrator :done, 2026-06-05, 20d
JIT agent generation :done, 2026-07-10, 10d
section Frontends
TUI + Emacs (ellie) :done, 2026-04-15, 45d
Acme (Plan 9) :done, 2026-05-20, 30d
Web UI + httpgw :done, 2026-06-01, 25d
KDE/Plasma :done, 2026-06-10, 40d
section Control Planes
D-Bus adapter (ollied) :done, 2026-06-25, 20d
D-Bus embedded in srv :done, 2026-07-15, 10d
section Late Evolution
Remote execution :done, 2026-07-05, 15d
Elevation broker :done, 2026-07-10, 15d
@ -48,52 +37,35 @@ gantt
FUSE → 9P client :done, 2026-07-25, 5d
The Great Flattening :done, 2026-07-29, 2d
```
## Phase 1: Monorepo Bootstrap (Apr 11)
Started as independent git repos unified under a monorepo with submodules.
Initial components:
- **agent** — agent loop (Go): backend dispatch, tool execution, session state
- **fs/session** — 9P file server exposing agent sessions as a synthetic filesystem
- **tui** — terminal UI (later removed)
- **el** — Emacs integration (ellie.el)
Build system was `mkfile` (Plan 9 make), later `Makefile`, finally `justfile`.
## Phase 2: The 9P Decision (Apr 14–28)
The defining architectural choice: **every agent primitive is a file**.
Sessions are directories. Sending a prompt is writing to `session/{id}/prompt`.
Reading state is `cat session/{id}/state`. This made the control plane
protocol-agnostic — any program that can read/write files can be a frontend.
Key files crystallized: `prompt`, `chat`, `state`, `ctl`, `cfg`, `statewait`.
The `session/new` file (write key=value pairs, read back session ID) became the
session factory.
## Phase 3: Tool System (Apr 28 – May 10)
Tools evolved through several incarnations:
1. **MCP servers** (denote-mcp, 9beads-mcp) — external processes, heavyweight
2. **Shell/Python scripts** in `~/.config/ollie/tools/` — lightweight, sandboxed
3. **execute_code** as the single built-in tool — all scripts invoked through it
4. **call_tool/pipe** — named tool dispatch and cross-tool pipelines
5. **Tool registry** (final form) — dynamic lazy-loading via `tool_list`/`tool_load`/`tool_active`; `execute_code` renamed to `shell`; tools hot-reloadable without server restart
The file tools (`file_read`, `file_write`, `file_edit`, `file_grep`, `file_glob`)
were extracted into standalone Python scripts. Memory (`memory_remember`,
`memory_recall`) and reasoning (`reasoning_think`) followed the same pattern.
MCP servers were removed early. Simple scripts won over complex daemons.
## Phase 4: Planning System Churn (May – Jul)
Planning went through the most iterations of any subsystem:
1. `pl/` directory namespace with flat file-per-task
2. `plan_create` / `plan_complete` tools
3. **Beads** integration (external issue tracker) — made optional, then dropped
@ -101,18 +73,13 @@ Planning went through the most iterations of any subsystem:
5. Inline markdown plans forbidden
6. **Final form**: a single `session/{id}/plan` file (markdown checklist), written by
the agent, persisted across context compaction
The lesson: planning needed to be simple, agent-controlled, and not bureaucratic.
## Phase 5: Multi-Agent Coordination (May 25 – Jun 15)
Three patterns emerged:
- **subagent_spawn** — fire-and-forget session creation, parent never blocks
- **subagent_generate** — JIT agent config generation (role, constraints, tools)
- **cascade** — script-driven fan-out with `-max-workers`, `-retries`, optional
synthesis step
```mermaid
flowchart TB
UP["User prompt"]
@ -123,7 +90,6 @@ flowchart TB
CASCADE["u/cascade\n(spawn + throttle)"]
CW1["Cascade Worker"]
CW2["Cascade Worker"]
UP --> PARENT
PARENT -- subagent_spawn --> W1
PARENT -- subagent_spawn --> W2
@ -135,23 +101,18 @@ flowchart TB
CASCADE --> CW1
CASCADE --> CW2
```
Inter-agent communication uses the filesystem: agents write to each other's
`prompt` file.
## Phase 6: Frontend Proliferation
```mermaid
flowchart TB
subgraph Core["agent.Agent"]
AG["Agent Engine"]
end
subgraph Surfaces["Integration Surfaces"]
P9["9P Filesystem\n(session/ namespace)"]
DB["D-Bus\n(org.ollie.SessionManager)"]
end
subgraph Frontends
SH["s/sh (terminal)"]
ACME["acme (Plan 9)"]
@ -160,35 +121,26 @@ flowchart TB
KP["KDE Plasmoid"]
KK["Kate Plugin"]
KR["KRunner"]
WEB["Web UI"]
HTTP["curl / scripts"]
end
AG --> P9
AG --> DB
SH --> P9
ACME --> P9
EL --> P9
HTTP --> P9
KG --> DB
KP --> DB
KK --> DB
KR --> DB
WEB --> DB
```
- **s/sh** — shell script frontend (bash, briefly rc, back to bash)
- **acme** — Plan 9 editor integration with `Oi` (quick query), `Kmpl` (completion)
- **ellie.el** — Emacs with ghost-text completion
- **httpgw** — HTTP→9P gateway
- **webui** — browser frontend via httpgw
- **ollie-kde** — full Plasma integration: plasmoid, KRunner, Kate plugin, standalone GUI, GUI automation tools
- **TUI** — removed (Jul) in favor of s/sh and richer GUIs
## Phase 7: Security Model (May – Jul)
```mermaid
flowchart LR
subgraph Permissions["9P Permissions"]
@ -196,7 +148,6 @@ flowchart LR
AGENT["Agent → restricted"]
PEERS["Peers → read-only"]
end
subgraph Execution["Execution Sandbox"]
AGT["Agent"]
SANDBOX["landrun sandbox\n(restricted fs)"]
@ -204,59 +155,42 @@ flowchart LR
ELEV["x/elevate\n(socket broker)"]
PRIV["Privileged Action"]
end
AGT --> SANDBOX --> TOOLS
TOOLS -- needs escape --> ELEV --> PRIV
```
Evolution:
1. No sandboxing initially
2. YAML-based sandbox configs (landrun) for execute_code
3. Per-session 9P identity — agents can't read other sessions' tools
4. Elevation broker: socket-based, user-confirmed privilege escalation
5. SSH agent proxy added then removed (too much attack surface)
6. Final: integrated elevation broker replaces separate superpowerd adapter
## Phase 8: D-Bus as Second Control Plane (Jun 25 – Jul)
Originally everything was 9P-only. D-Bus was added for desktop integration:
1. Separate `ollie-dbus` daemon (ollied)
2. Embedded directly into `olliesrv` (9P server got `-no9p` flag)
3. KDE uses D-Bus exclusively; acme/sh/el use 9P
4. **9P and D-Bus do not share sessions** — independent stores, same core
## Phase 9: Remote Execution (Jul 5–19)
Split-brain architecture: orchestration stays local, code execution on a remote
machine via SSH.
- `ollie-remote` binary auto-deployed via SSH bootstrap
- Tools embedded in remote binary
- Streaming output notifications back to local session
- Session persistence across remote restarts
## Phase 10: Tool Registry (Jul 20–29)
The final major architectural shift — from "agent knows all tools upfront" to
lazy discovery:
1. Tools embed their own `ollie:prompt` metadata blocks
2. `tool_list` — discover available tools and descriptions
3. `tool_load` — promote a tool to a native callable
4. `tool_active` — introspect loaded tools
5. Skills get the same treatment: `skill_list`, `skill_load`, `skill_active`
This solved system prompt bloat — only load what's needed per task.
## Phase 11: The Great Flattening (Jul 29–30)
Largest single-day structural change: **−4,573 lines net** across the codebase.
Eliminated accumulated abstractions that no longer served a purpose.
### Core (−4,400 lines)
- **Killed `agent.Core` interface** — concrete `*agent.Agent` used directly.
Nobody else implemented Core; the interface just added indirection.
- **Killed `Dispatcher` indirection** in toolsrv — `toolsrv.Server` called directly.
@ -268,9 +202,7 @@ Eliminated accumulated abstractions that no longer served a purpose.
- **Threaded context.Context** properly through daemon → session → agent
(replaced ad-hoc interrupt mechanisms with cancellation).
- Dead code removal: `GlobalToolNames`, `ExtractReturnSchema`.
### 9P server (−214 lines)
- **Killed `mgr/` package entirely** — replaced `Manager` struct with package
functions in `fs/session/`. The `*fs.Tree` IS the session collection; `rootState`
(unexported) lives in `tree.Data`. CRUD via `NewRoot`, `Lookup`, `Create`,
@ -283,17 +215,13 @@ Eliminated accumulated abstractions that no longer served a purpose.
files, FUSE kernel saw 0 bytes and never issued a read.
- **Fixed `session.All()`** — was looking at `root.Children()` (empty) instead
of `rootState.sessions`. Broke D-Bus ListSessions and GUI session restore.
### Frontends
- **acme**: paths updated `s/` → `session/`, agent files route through
`session/{id}/agent/{aid}/`.
- **ellie.el**: same path migration, added `ellie--agent-dir` for agent ID
discovery.
- **KDE GUI**: no changes needed (D-Bus operates on Go objects, not paths).
### New filesystem layout
```
session/
├── new write key=value to create session
@ -325,14 +253,11 @@ session/
│ ├── tail exec helper
│ └── proc/ detached process output
```
### Architecture after (single Go module, no submodules for core/9p)
```
*fs.Tree (root) ← IS the session collection
└── .Data = *rootState ← sessions map, config, nextUID
└── sessions[id] = *Session ← owns Agent, context, cancel
Package functions (no Manager):
session.NewRoot(cfg) → *fs.Tree
session.Lookup(tree, id) → *Session
@ -342,21 +267,15 @@ Package functions (no Manager):
session.RenameFromRoot(tree, old, new)
session.Shutdown(tree)
```
### SLOC after flattening
| Component | Lines |
|-----------|-------|
| agent + backend + toolsrv + session | 10,437 |
| fs/session + cmd/olliesrv | 7,925 |
| kde gui | 12,331 |
| el + acme + httpgw | 1,845 |
| **Total** | **32,538** |
Zero dead exported functions remain (verified via LSP + grep across all repos).
## Current Topology
```
ollie/ ← single Go module
├── agent/ ← agent loop, history, hooks, commands
@ -372,9 +291,8 @@ ollie/ ← single Go module
├── log/ ← structured logging
├── paths/ ← XDG path resolution
├── mount/ ← 9P FUSE mount client
├── cmd/ ← binaries (olliesrv, ollie-9p, ollie-httpgw, ollie-remote)
├── cmd/ ← binaries (olliesrv, ollie-9p, ollie-remote)
├── kde/ ← KDE Plasma (submodule)
├── webui/ ← browser frontend (submodule)
├── tools/ ← 37 tool scripts (Python)
├── scripts/{s,u,x}/ ← filesystem-hosted scripts
├── agents/ ← agent configs (JSON)
@ -383,12 +301,9 @@ ollie/ ← single Go module
├── sandbox/ ← landrun sandbox profiles (YAML)
└── doc/ ← documentation
```
## Principles That Emerged
1. **Filesystem-as-API** — everything is read/write on synthetic files. No
custom protocols needed.
2. **Submodule independence** — KDE and webui have their own repos, tests, build.
Monorepo coordinates versions.
3. **Scripts over servers** — MCP servers removed early. Simple scripts won.
4. **Progressive disclosure** — lazy tool/skill loading keeps system prompts
@ -399,9 +314,7 @@ ollie/ ← single Go module
Same core, different surfaces.
7. **Security by default** — sandboxed execution with explicit elevation,
per-session identity.
## Dead Ends and Reversals
| What | Why it was removed |
|------|-------------------|
| MCP servers | Too heavyweight; scripts are simpler and faster |

View File

@ -495,159 +495,6 @@ OLLIE_TOOLS_PATH=~/mnt/toolserver/t
OLLIE_TRANSCRIPT_PATH=~/mnt/archive/tr
```
## HTTP gateway
`ollie-httpgw` is an HTTP gateway that translates REST-style HTTP calls into 9P
filesystem operations. It lets any HTTP client — including the web UI, `curl`, or
custom scripts — interact with `olliesrv` without a 9P client library.
### Build
```sh
just httpgw
```
Produces `~/bin/ollie-httpgw`.
### Start and stop
Like `olliesrv`, the gateway supports daemon management:
```sh
ollie-httpgw start # start in background (daemonizes)
ollie-httpgw fgstart # start in foreground
ollie-httpgw stop # stop the daemon
ollie-httpgw status # check if running
```
### Run (legacy)
For backwards compatibility, running without a subcommand starts in foreground:
```sh
ollie-httpgw # connect to local olliesrv via namespace; listen on :8080
ollie-httpgw -listen :9090 # different port
ollie-httpgw -net tcp -addr host:9564 # connect to remote olliesrv over TCP
```
**Flags**
| Flag | Default | Description |
|---|---|---|
| `-listen` | `:8080` | HTTP listen address |
| `-service` | `ollie` | 9P service name in the local namespace |
| `-net` | `` | Network type (`tcp`, `unix`); empty = use namespace |
| `-addr` | `` | 9P server address; required when `-net` is set |
The gateway must be able to reach `olliesrv`. For a local server started with
`olliesrv`, the defaults work as-is. For a remote server started with
`olliesrv -tcp :9564`, use `-net tcp -addr remotehost:9564`.
### HTTP → 9P mapping
Each HTTP method maps to a 9P operation:
| Method | 9P operation | Example |
|---|---|---|
| `GET` | read file / list dir | `GET /s/idx` |
| `POST` | write to file | `POST /session/new`, `POST /session/{id}/prompt` |
| `DELETE` | remove | `DELETE /session/{id}` |
| `PATCH` | rename (JSON `{"name":"..."}`) | `PATCH /session/{id}` |
| `HEAD` | stat (metadata in `X-9p-*` headers) | `HEAD /session/{id}` |
The full API is described in `httpgw/openapi.json` and served live at
`GET /openapi.json`.
### Key endpoints
```sh
GET /s/idx # list sessions (JSON array)
GET /session/{id}/cfg # session config (key-value JSON)
GET /session/{id}/chat # conversation transcript (plain text)
POST /session/new # create session (JSON: {cwd, backend, model, agent})
POST /session/{id}/prompt # submit prompt (JSON: {prompt: "..."})
POST /session/{id}/ctl # send control command (JSON: {ctl: "stop"})
DEL /session/{id} # kill and remove session
GET /backends # list available backends
GET /a/ # agent configs
GET /session/{id}/t/ # tool scripts
GET /m/ # memory files
GET /sk/ # skills
```
```sh
# Examples with curl:
curl http://localhost:8080/s/idx
curl -X POST http://localhost:8080/session/new \
-d '{"cwd":"/home/user/project","backend":"anthropic"}'
curl -X POST http://localhost:8080/s/mysession/prompt \
-d '{"prompt":"summarize the recent commits"}'
```
## Web UI
The web UI is a Preact single-page application that talks to `ollie-httpgw`. It
provides session management, a chat interface, and a basic 9P filesystem browser
in the browser.
### Build
```sh
just webui
```
Requires Node.js and npm. Output lands in `webui/dist/`.
### Serve
The `dist/` folder is a static site. Serve it with any HTTP server and proxy the
API paths to `ollie-httpgw`:
**nginx example:**
```nginx
server {
listen 3000;
root /path/to/ollie/webui/dist;
index index.html;
location / { try_files $uri /index.html; }
location ~ ^/(s|a|m|t|sk|backends|openapi\.json)(/|$) {
proxy_pass http://localhost:8080;
}
}
```
**Python one-liner (local testing only):**
```sh
cd webui/dist && python3 -m http.server 3000
# API calls will fail — use the Vite dev server instead for local testing
```
### Development
Vite's dev server proxies API paths to httpgw automatically:
```sh
# Terminal 1: gateway
ollie-httpgw -listen :8080
# Terminal 2: dev server (hot-reload, proxied API)
cd webui && npm run dev
# Open http://localhost:5173
```
The proxy configuration is in `webui/vite.config.ts` and targets `localhost:8080`
by default.
### Features
- Session list in a sidebar with state, model, backend, and cwd
- Create, rename, and kill sessions
- Send prompts; streaming via 2-second poll
- Stop a running agent
- Markdown rendering of assistant responses
- 9P filesystem browser (navigate `s/`, `a/`, `m/`, `sk/`)
- Two built-in themes: `midnight` (dark) and `acme` (Plan 9-inspired light); choice

View File

@ -1,7 +1,7 @@
# Ollie monorepo build system
#
# Two variants:
# just Default (KF6): core, 9p, acme, kde, httpgw, webui, emacs
# just Default (KF6): core, 9p, acme, kde, emacs
# just kf5 KF5/Qt5 variant (Plasma 5.27)
#
# Build and install are separate phases. No sudo.
@ -20,9 +20,9 @@ kf5: build-kf5 install-kf5
# === Build targets ===
build: core ninep acme httpgw webui kde
build: core ninep acme kde
build-kf5: core ninep acme httpgw webui kde-kf5
build-kf5: core ninep acme kde-kf5
core:
@echo === Building ollie-core ===
@ -40,14 +40,6 @@ acme:
install -m755 cmd/Ollie/scripts/Oi {{bin}}/Oi
install -m755 cmd/Ollie/scripts/Theo {{bin}}/Theo
httpgw:
@echo === Building ollie-httpgw ===
go build -o {{bin}}/ollie-httpgw ./cmd/ollie-httpgw/
webui:
@echo === Building ollie-webui ===
just -f webui/justfile webui
ollie-remote:
rm -rf cmd/ollie-remote/tools && cp -a contrib/tools cmd/ollie-remote/tools
@echo === Building ollie-remote ===
@ -70,7 +62,7 @@ install: test install-data install-scripts install-contrib install-kde install-e
install-kf5: test install-data install-scripts install-contrib install-kde-kf5 install-el
install -m644 contrib/prompts/session-9p.md {{cfg}}/prompts/session.md
@echo === Install complete \(KF5\) ===
@echo === Install complete (KF5) ===
@echo 'Restart Plasma to pick up KDE plugins.'
install-kde:
@ -193,8 +185,6 @@ install-scripts:
install -m755 contrib/scripts/u/optimize {{cfg}}/scripts/u/optimize
install -m755 contrib/scripts/u/complete {{cfg}}/scripts/u/complete
install -m755 contrib/scripts/u/cascade {{cfg}}/scripts/u/cascade
install -m755 contrib/scripts/x/bd {{cfg}}/scripts/x/bd
install -m755 contrib/scripts/x/prime {{cfg}}/scripts/x/prime
install -m755 contrib/scripts/x/freeloader {{cfg}}/scripts/x/freeloader
@ -219,7 +209,6 @@ install-contrib:
uninstall:
@echo === Uninstalling ollie ===
rm -f {{bin}}/ollie-httpgw
rm -f {{bin}}/Ollie {{bin}}/Kmpl {{bin}}/Oi {{bin}}/Theo
rm -f {{bin}}/ollie-remount {{bin}}/ollie-watchdog
rm -rf {{cfg}}
@ -235,8 +224,7 @@ uninstall:
@echo === Uninstall complete ===
uninstall-kf5:
@echo === Uninstalling ollie \(KF5\) ===
rm -f {{bin}}/ollie-httpgw
@echo === Uninstalling ollie (KF5) ===
rm -f {{bin}}/Ollie {{bin}}/Kmpl {{bin}}/Oi {{bin}}/Theo
rm -f {{bin}}/ollie-remount {{bin}}/ollie-watchdog
rm -rf {{cfg}}
@ -249,7 +237,7 @@ uninstall-kf5:
rm -rf ~/.config/emacs/ellie
# Plasma env
rm -f ~/.config/plasma-workspace/env/99-ollie.sh
@echo === Uninstall complete \(KF5\) ===
@echo === Uninstall complete (KF5) ===
# === Test targets ===
@ -271,7 +259,6 @@ test-remote:
clean:
go clean ./agent/... ./backend/... 2>/dev/null || true
rm -f {{bin}}/ollie-httpgw
rm -f {{bin}}/Ollie {{bin}}/Kmpl {{bin}}/Oi {{bin}}/Theo
rm -f {{bin}}/ollie-remote
just -f kde/justfile clean
@ -282,8 +269,8 @@ help:
@echo 'Ollie monorepo build targets:'
@echo ' just Build + install (default, KF6)'
@echo ' just kf5 Build + install (KF5/Qt5, Plasma 5.27)'
@echo ' just build Build only (KF6): core, 9p, acme, kde, httpgw, webui'
@echo ' just build-kf5 Build only (KF5): core, 9p, acme, kde-kf5, httpgw, webui'
@echo ' just build Build only (KF6): core, 9p, acme, kde'
@echo ' just build-kf5 Build only (KF5): core, 9p, acme, kde-kf5'
@echo ' just install Install (KF6)'
@echo ' just install-kf5 Install (KF5)'
@echo ' just test Run all tests (core + 9p)'
@ -293,4 +280,4 @@ help:
@echo ' just uninstall-kf5 Remove all installed files (KF5)'
@echo ' just clean Clean build artifacts'
@echo ''
@echo 'Individual: just core | ninep | acme | httpgw | webui | kde | kde-kf5 | ollie-remote'
@echo 'Individual: just core | ninep | acme | kde | kde-kf5 | ollie-remote'

1
webui

@ -1 +0,0 @@
Subproject commit c760012f731704ff7a1a6caad7f5b24e6a76161b