diff --git a/9p b/9p
index 06a3e0f..e7dd398 160000
--- a/9p
+++ b/9p
@@ -1 +1 @@
-Subproject commit 06a3e0f5031c99a85d3088b7393f7a700b0a259c
+Subproject commit e7dd3987efcafeca91740d8e5c664a83343257c9
diff --git a/agents/taskmanager.json b/agents/taskmanager.json
index cdd95b5..d37e0e4 100644
--- a/agents/taskmanager.json
+++ b/agents/taskmanager.json
@@ -7,13 +7,15 @@
"maxSteps": 20,
"temperature": 0.3,
"maxTokens": 8192,
- "allowExecutors": ["call_tool"],
+ "allowExecutors": ["call_tool", "execute_code"],
"allowTools": [
"file_read",
"file_glob",
"file_grep",
"file_write",
- "file_edit"
+ "file_edit",
+ "subagent_generate",
+ "subagent_spawn"
],
"hooks": {
"turnError": [
diff --git a/core b/core
index 82ac183..9d4818a 160000
--- a/core
+++ b/core
@@ -1 +1 @@
-Subproject commit 82ac183357358b7a00b408a940e57ec1218a0c31
+Subproject commit 9d4818af8da4ce238181aa83bd9ed0332260f109
diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md
index 7355747..e294093 100644
--- a/doc/ARCHITECTURE.md
+++ b/doc/ARCHITECTURE.md
@@ -2,40 +2,380 @@
## Philosophy
-ollie is a small, extensible core library for building agentic systems. It has no binary of its own — frontends and consumers live in separate repos and import ollie as a dependency. The internal surface area is kept minimal; everything a consumer needs is exported under `pkg/`.
+ollie asks: *What if an AI agent were a Plan 9 process?* The answer is a small Go runtime that defines what an agent is, then exposes its state and behaviors as files in a 9P namespace. Orchestration, scheduling, workflows, and UIs are pushed out to the surrounding environment — shell scripts, editors, web apps, cron, containers — rather than being built into the agent itself.
-## Overview
+Design principles:
+- **Everything is a file.** Agent state, control, chat history, tools, memory — all accessed via filesystem reads and writes.
+- **Subtract, don't add.** The core is minimal; capabilities come from composing external scripts.
+- **One built-in tool.** `execute_code` is the only tool compiled into the core. Everything else is a script.
+- **No framework lock-in.** Frontends are decoupled via the filesystem interface; any program that can read/write files can drive an agent.
-
+## Repository Structure
-## Package Layout
+The project is a monorepo with Git submodules:
```
-pkg/
- agent/ — Core interface, agent loop, session and context management
- backend/ — Backend interface + implementations
- config/ — Agent config struct and loader
- env/ — Environment variable loading (env file + shell)
- log/ — Structured logging
- paths/ — Config and data directory resolution
- skills/ — Skill file discovery and loading
- store/ — Store interfaces and implementations (FlatDir, Session, Batch, Skill)
- tools/ — Server and Dispatcher interfaces
- tools/execute/ — execute.Server: execute_code
-
-internal/
- sandbox/ — landrun sandbox config and command wrapper
+ollie/
+├── core/ → ollie-core Go library: agent loop, backends, tools, config
+├── 9p/ → ollie-9p 9P filesystem server (olliesrv)
+├── httpgw/ → ollie-httpgw HTTP-to-9P gateway
+├── webui/ → ollie-webui Preact SPA (browser frontend)
+├── acme/ → ollie-acme Plan 9 acme editor frontend
+├── el/ → ollie-el Emacs frontend (ellie.el)
+├── skills/ → agent-skills Domain knowledge files
+├── agents/ Agent config JSONs (default, coding, orchestrator, worker, ...)
+├── prompts/ Prompt templates (markdown)
+├── tools/ Tool scripts (file_read, lsp_*, memory_*, subagent_*, ...)
+├── scripts/ User-facing scripts organized by namespace (s/, u/, x/)
+├── sandbox/ Sandbox config YAML
+└── doc/ Documentation
```
-### Why internal/sandbox?
+## System Overview
-The sandbox package is an implementation detail of `pkg/tools/execute`. It has no stable public API and no reason to be imported by consumers directly. Keeping it internal prevents accidental coupling.
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ Frontends │
+│ s/sh (terminal) │ acme │ ellie (Emacs) │ Web UI (Preact)│
+└────────┬──────────┴────┬───┴────────┬─────────┴───────┬─────────┘
+ │ 9P direct │ 9P direct │ 9P direct │ HTTP
+ │ │ │ ▼
+ │ │ │ ┌─────────────┐
+ │ │ │ │ ollie-httpgw│
+ │ │ │ └──────┬──────┘
+ ▼ ▼ ▼ │ 9P
+┌────────────────────────────────────────────────────────▼────────┐
+│ olliesrv (9P server) │
+│ ┌──────────┐ ┌──────────┐ ┌──────┐ ┌────┐ ┌────┐ ┌────┐ │
+│ │ Sessions │ │ Memory │ │Skills│ │Tmp │ │ Tr │ │ ...│ │
+│ │ s/ │ │ m/ │ │ sk/ │ │tmp/│ │tr/ │ │ │ │
+│ └────┬─────┘ └──────────┘ └──────┘ └────┘ └────┘ └────┘ │
+└───────┼─────────────────────────────────────────────────────────┘
+ │ per-session
+ ▼
+┌────────────────────────────────────────────────────────────────┐
+│ agent.Core (per session) │
+│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
+│ │ Agent Loop │ │ Session │ │ tools.Dispatcher │ │
+│ │ (loop.go) │ │ (messages, │ │ ┌──────────────────┐ │ │
+│ │ │ │ task state, │ │ │ execute.Server │ │ │
+│ │ stream ←────┼──┤ compaction) │ │ │ (execute_code, │ │ │
+│ │ dispatch ───┼──┼───────────────┼──┤ │ call_tool, pipe)│ │ │
+│ └─────────────┘ └──────────────┘ │ └────────┬─────────┘ │ │
+│ └───────────┼────────────┘ │
+└──────────────────────────────────────────────────┼──────────────┘
+ │
+ ┌──────────────────────────────────────────┼──────┐
+ │ Tool Execution │ │
+ │ ┌─────────────┐ ┌───────────────────┐ │ │
+ │ │ landrun │ │ Tool Scripts │ │ │
+ │ │ (sandbox) │ │ (OLLIE_TOOLS_PATH)│ │ │
+ │ └─────────────┘ └───────────────────┘ │ │
+ └──────────────────────────────────────────────────┘
+ │
+ ▼
+ ┌─────────────────┐
+ │ LLM Backends │
+ │ Ollama│OpenAI│ │
+ │ Anthropic│... │
+ └─────────────────┘
+```
+
+## Core Library (`core/`)
+
+The core is a Go library (`ollie/pkg/`) with no binary. Frontends import it as a dependency.
+
+### Package Layout
+
+| Package | Purpose |
+|---|---|
+| `pkg/agent/` | Agent loop, Core interface, Session, TaskState, hooks, prompt resolution |
+| `pkg/backend/` | Backend interface + implementations (Ollama, OpenAI, Anthropic) |
+| `pkg/config/` | Agent config struct and JSON loader |
+| `pkg/env/` | Environment variable loading (env file + shell) |
+| `pkg/log/` | Structured logging |
+| `pkg/paths/` | Config and data directory resolution |
+| `pkg/tools/` | Server and Dispatcher interfaces |
+| `pkg/tools/execute/` | The execute.Server (execute_code, call_tool, pipe) |
+| `internal/sandbox/` | Landlock sandbox config and command wrapper |
+
+### Key Interfaces
+
+**`agent.Core`** — the public API frontends drive:
+```go
+type Core interface {
+ Submit(ctx context.Context, input string)
+ Interrupt(cause error) bool
+ Queue(prompt string)
+ State() string // "idle" | "thinking" | "calling: "
+ WaitChange(ctx context.Context, field, current string) (string, bool)
+ Close()
+ // ... model/backend info, CWD, usage, cost, etc.
+}
+```
+
+**`backend.Backend`** — LLM provider contract:
+```go
+type Backend interface {
+ ChatStream(ctx context.Context, messages []Message, tools []Tool, params GenerationParams) (<-chan StreamEvent, error)
+ Name() string
+ Model() string
+ SetModel(model string)
+ ContextLength(ctx context.Context) int
+ Models(ctx context.Context) []string
+}
+```
+
+**`tools.Server`** — any tool provider:
+```go
+type Server interface {
+ ListTools() ([]ToolInfo, error)
+ CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error)
+}
+```
+
+**`tools.Dispatcher`** — routes tool calls to servers:
+```go
+type Dispatcher interface {
+ AddServer(name string, s Server)
+ ListTools() ([]ToolInfo, error)
+ Dispatch(ctx context.Context, server, tool string, args json.RawMessage) (json.RawMessage, error)
+}
+```
+
+### Agent Loop (`pkg/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 `tools.Dispatcher`
+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
+- **Stall detection**: 5 rounds with unchanged LastAction triggers a nudge
+- **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 (`pkg/agent/session.go`)
+
+`Session` 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 75% of model limit:
+ - **Cold zone**: structured task state summary
+ - **Warm zone**: one-line decision index (10 messages)
+ - **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 (`pkg/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 |
+| `preTurn` | Before each turn (deprecated) | 0=inject stdout, 2=block |
+| `postTurn` | After each turn | 0=ok |
+| `preTool` | Before each tool call | 0=ok, 2=block execution |
+| `postTool` | After each tool call | 0=append, 2=replace result |
+| `preCompact` | Before compaction | 0=ok |
+| `postCompact` | After compaction | 0=ok |
+| `turnError` | On backend error | 0=handled (skip retries) |
+
+### Prompt Resolution (`pkg/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 (`9p/`)
+
+`olliesrv` is the central daemon. It implements a 9P2000 file server that exposes the entire agent namespace:
+
+### Namespace Layout
+
+| Path | Contents |
+|---|---|
+| `s/` | Sessions — each session is a directory |
+| `s/{id}/chat` | Full chat history (streamable) |
+| `s/{id}/prompt` | Write to submit a prompt |
+| `s/{id}/ctl` | Control commands (stop, kill, compact, model, ...) |
+| `s/{id}/cfg` | Session config (key=value) |
+| `s/{id}/state` | Current state (idle/thinking/calling) |
+| `s/{id}/statewait` | Blocking read until state changes |
+| `s/{id}/plan` | Session-scoped markdown checklist |
+| `s/{id}/t/` | Tool scripts visible to this session |
+| `m/` | Persistent memory files |
+| `sk/` | Domain skill files |
+| `a/` | Agent definitions (JSON) |
+| `p/` | Prompt templates |
+| `u/` | Utility scripts |
+| `x/` | System plugins and exec helpers |
+| `tr/` | Saved transcripts |
+| `tmp/` | Temporary storage |
+
+### Per-Agent 9P Principals
+
+Each session gets its own FUSE mount at `/tmp/ollie-mount-{uid}` via `ollie-9p-mount -u {uid}`. The session's numeric UID is used as the 9P user principal, enabling permission enforcement:
+- An agent cannot write to its own `prompt` file (mode `0466` — owner has no write)
+- Cross-session access is controlled by standard Unix permission semantics
+- The `$OLLIE` env var points to the session's private mount
+
+### Permission Model (`9p/fs/perm.go`)
+
+All permissions are declared in a single registry (`fs.Perms`). Key design:
+- `prompt` is mode `0466` — 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 Manager (`9p/session/`)
+
+The `Manager` handles session lifecycle:
+- Creating sessions (write to `s/new`)
+- Killing sessions (delete `s/{id}` or write "kill" to `s/{id}/ctl`)
+- Renaming sessions
+- Assigning unique numeric UIDs for 9P principals
+- Wiring up `agent.Core` with backends and tool servers
+
+## Tool System
+
+### Architecture
+
+The tool system has exactly one built-in server (`execute.Server`) that exposes three executors:
+
+| Executor | Purpose |
+|---|---|
+| `execute_code` | Run inline code steps (bash, python3, perl, lua, ...) |
+| `call_tool` | Run named scripts from `OLLIE_TOOLS_PATH` |
+| `pipe` | Sequential pipeline chaining stdout→stdin across steps |
+
+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
+- `reasoning_think` — scratchpad for externalized reasoning
+- `subagent_spawn`, `subagent_generate` — sub-agent lifecycle
+- `route`, `cascade`, `escalate` — tiered delegation
+
+Scripts annotated `ollie:parallel read` can be fanned out concurrently by the agent loop.
+
+### Sandboxing (`internal/sandbox/`)
+
+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
+filesystem:
+ ro: ["/usr", "/lib", "/etc", "{HOME}/.config/ollie"]
+ rox: ["/usr/bin", "/usr/local/bin"]
+ rw: ["{CWD}", "{TMPDIR}"]
+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 (`pkg/backend/`)
+
+All backends implement the streaming-only `Backend` interface:
+
+| Backend | Wire Format | Notes |
+|---|---|---|
+| `OllamaBackend` | `/api/chat` | Local models, context length from `/api/show` |
+| `OpenAIBackend` | `/v1/chat/completions` | OpenAI, OpenRouter, any compatible API |
+| `AnthropicBackend` | `/v1/messages` | Extended thinking, prompt caching |
+
+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 `s/new` → new session directory appears
+2. **Prompt**: write task to `s/{id}/prompt`
+3. **Observe**: read `s/{id}/state` or block on `s/{id}/statewait`
+4. **Result**: child writes to `s/{parent_id}/prompt` when done
+
+### Delegation Tools
+
+| Tool | Semantics |
+|---|---|
+| `route` | Orchestrator dispatch; accepts `--tier=worker\|expert` and `--backend=` |
+| `cascade` | Cheap worker delegation (fixed tier) |
+| `escalate` | Strong expert delegation (fixed tier) |
+| `subagent_spawn` | Raw session creation with full control |
+| `subagent_generate` | JIT agent identity generation |
+
+Tier resolution uses `OLLIE_TIER_{WORKER|EXPERT}_{backend}` env vars to map tiers to specific models per backend.
+
+### Callback Protocol
+
+```
+[from={session_id}]
+STATUS: done|error
+SUMMARY:
+ARTIFACTS:
+```
+
+The parent never actively waits — results arrive as queued prompts.
+
+## HTTP Gateway (`httpgw/`)
+
+`ollie-httpgw` is a stateless translation layer:
+
+| HTTP Method | 9P Operation |
+|---|---|
+| GET | read / stat / readdir |
+| PUT | write (truncate) |
+| POST | create |
+| DELETE | remove |
+| HEAD | stat (metadata in headers) |
+| PATCH | wstat (rename) |
+
+Connects to `olliesrv` via Plan 9 namespace (local) or TCP (remote). Sensitive paths (`tr/`, `statewait`, `systemprompt`) are blocked at the gateway layer.
+
+## Frontends
+
+All frontends interact with the same 9P namespace:
+
+| Frontend | Technology | Connection |
+|---|---|---|
+| `s/sh` | Terminal (readline) | 9P filesystem directly |
+| `ollie-acme` | Plan 9 acme editor | 9P filesystem directly |
+| `ellie` | Emacs Lisp | 9P filesystem directly |
+| Web UI | Preact SPA | HTTP via `ollie-httpgw` |
+
+Frontends are fully decoupled — they work identically against local or remote `olliesrv` instances.
+
+## 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/`. The key fields are:
-
-**`prompt`** — an array of shell commands whose stdout is concatenated to form the system prompt. Each command runs in the session's working directory with access to `$OLLIE` (mount path) and `$PWD`. Commands that exit non-zero are skipped; exit code 2 blocks the turn entirely.
+Agent configs are JSON files in `~/.config/ollie/agents/`:
```json
{
@@ -43,243 +383,64 @@ Agent configs are JSON files in `~/.config/ollie/agents/`. The key fields are:
"$OLLIE/x/prime SYSTEM_PROMPT",
"$OLLIE/x/prime tools-file",
"$OLLIE/x/prime tools-reasoning"
- ]
+ ],
+ "hooks": {
+ "agentSpawn": ["$OLLIE/x/elevate start"],
+ "turnError": ["$OLLIE/x/freeloader"]
+ },
+ "trustedTools": ["escalate"],
+ "maxTokens": 8192,
+ "temperature": 0.7,
+ "maxSteps": 50
}
```
-**`hooks`** — lifecycle callbacks executed at specific points:
-
-| Hook | When | Use case |
-|---|---|---|
-| `agentSpawn` | Session creation | Start elevation adapter, initialize state |
-| `preTurn` | Before each turn (deprecated; use `prompt`) | Legacy prompt injection |
-| `postTurn` | After each turn | Task list display, logging |
-| `preCompact` | Before context compaction | Save state |
-| `postCompact` | After context compaction | Notify user |
-| `turnError` | On turn error (rate limit, transient failure) | Fallback handling, model switching |
-
-**`trustedTools`** — array of tool names allowed to run outside the sandbox.
-
-
-**Generation parameters** — `maxTokens`, `maxCompletionTokens`, `temperature`, `topP`, `topK`, `minP`, `topA`, `frequencyPenalty`, `presencePenalty`, `repetitionPenalty`, `reasoning`, `reasoningEffort`, `includeReasoning`, `responseFormat`, `stop`, `verbosity`. Set in the agent JSON; overridable at runtime via `s/{id}/cfg`.
-
-## Extension Points
-
-Consumers extend ollie by implementing or composing its interfaces:
-
-- **`backend.Backend`** — swap or add LLM backends
-- **`tools.Server`** — add a new tool server; all servers are equal
-- **`tools.Dispatcher`** — replace the tool router entirely (e.g. remote dispatcher, mock)
-- **`agent.Core`** — the agent's public API; frontends drive it without knowing internals. Includes `Close()` to release session resources (tmpdir under `/tmp/ollie/`).
-
-All tool servers implement the same `tools.Server` interface. There is no special "builtin" concept — `execute.Server` is registered by name, and is torn down and recreated on `/agent` switches.
-
-Each built-in server package exports a `Decl` function (`func Decl(...) func() tools.Server`) — a parameterized factory that produces a fresh server instance. `tools.NewDispatcherFunc` takes a map of name→Decl result and returns a `func() tools.Dispatcher` suitable for `agent.AgentCoreConfig.NewDispatcher`.
-
-`execute.Decl(cwd string)` accepts a working directory used as `cmd.Dir` for sandboxed commands. Pass `""` to fall back to `os.Getwd()`.
-
-**Adding a new tool server:**
-1. Implement `tools.Server` in a new package under `pkg/tools/`
-2. Export `func Decl(...) func() tools.Server`
-3. Register the Decl result by name in `tools.NewDispatcherFunc` — no frontend changes needed
+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
```
-Frontend
- └── agent.Core.Submit(prompt)
- └── loop.run()
- ├── backend.ChatStream() — LLM call
- └── tools.Dispatcher.Dispatch(...) — tool dispatch
- ├── "execute" → tools.Server
- │ execute_code
- └── "" → tools.Server
+User input (via any frontend)
+ │
+ ▼ write to s/{id}/prompt
+olliesrv
+ │
+ ▼ agent.Core.Submit()
+agent loop (loop.go)
+ │
+ ├── backend.ChatStream() → LLM API call (streaming)
+ │
+ ├── tools.Dispatcher.Dispatch() → execute.Server
+ │ │
+ │ ├── {code} steps → landrun sandbox → subprocess
+ │ └── {tool} steps → landrun sandbox → tool script
+ │
+ ├── session.update() → append to message history
+ │
+ └── loop (until no tool calls)
+ │
+ ▼ event bus → session log
+Frontend reads s/{id}/chat (streaming)
```
-All tool calls go through one `tools.Dispatcher.Dispatch` path. Every registered server is a `tools.Server`; the dispatcher routes by name and is agnostic to how any server is implemented.
+## Extension Points
-## execute_code
+| What | How |
+|---|---|
+| Add an LLM backend | Implement `backend.Backend` |
+| Add a tool | Drop an executable into `OLLIE_TOOLS_PATH` |
+| Add a tool server | Implement `tools.Server`, register in `NewDispatcherFunc` |
+| Add a frontend | Read/write the 9P namespace (directly or via httpgw) |
+| 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/` |
-`execute_code` is the only built-in tool. It is a unified primitive that handles simple execution, sequential composition, and parallel fan-out through a single `steps` array of pipeline stages.
+## Key Design Decisions
-Each stage is one of:
-- `{code, language}` — inline code (default: bash)
-- `{tool, args}` — named script from `OLLIE_TOOLS_PATH`; language detected from shebang
-- `{parallel: [...]}` — concurrent fan-out; each element is a `{code}` or `{tool}` step; outputs concatenated in submission order
-
-Stages run sequentially, each stage's stdout fed to the next stage's stdin. A single simple stage is the degenerate case and returns raw output directly.
-
-```
-steps=[{code: "ls"}] — simple execution
-steps=[{code: "grep error log"}, {code:"wc"}] — sequential pipeline
-steps=[{parallel:[{code:"wc a"},{code:"wc b"}]}] — parallel fan-out
-steps=[{parallel:[...]}, {code:"sort"}] — fan-out then transform
-```
-
-`OLLIE_TOOLS_PATH` defaults to `~/.config/ollie/tools`. `{tool}` steps resolve scripts from this path.
-
-## Script Namespaces
-
-User-facing scripts are organized by the 9P namespace they are hosted under and installed to `~/.config/ollie/scripts/{ns}/`:
-
-| Namespace | Purpose | Scripts |
-|---|---|---|
-| `s/` | Session management and one-shot queries | `sh`, `ls`, `kill`, `cleanup`, `b`, `bfg`, `bbg` |
-| `u/` | Utility scripts; compositions of `s/` primitives | `complete`, `optimize`, `cascade`, `escalate` |
-| `x/` | System exec and plugins | `elevate`, `elevate-adapter-*`, `bd`, `prime`, `freeloader`, `task` |
-
-`s/b` is the core one-shot runner. `s/bfg` and `s/bbg` are thin wrappers: `s/bfg` blocks until the agent finishes and writes the result to stdout; `s/bbg` returns immediately and prints the session path. `u/` scripts have no special server backing — they are executable scripts that compose other namespaces via the filesystem.
-
-## Tool Scripts
-
-All capabilities beyond `execute_code` itself are implemented as tool scripts in `OLLIE_TOOLS_PATH`, invoked via `{tool}` steps. The agent discovers available scripts via `s/{sid}/t/idx`.
-
-Examples from the default install: `file_read`, `file_write`, `file_edit`, `file_glob`, `file_grep`, `memory_remember`, `memory_recall`, `reasoning_think`, `lsp_definition`, `lsp_references`, `lsp_rename`, `lsp_symbols`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion`, `route`, `cascade`, `escalate`, `subagent_generate`, `subagent_spawn`, `denote_view`, `browser_screencap`.
-
-## Sub-agents
-
-Sub-agents are implemented as tool scripts (`subagent_spawn`, `cascade`, `route`, `escalate`) on top of the `s/` namespace. Each script writes a session spec to `s/new`, waits for the session directory to appear, then submits a prompt. All are fire-and-forget: the parent continues immediately and receives results asynchronously when the child writes to `s/{parent_session_id}/prompt`.
-
-**Routing and tiers**: `route` is the orchestrator's primary dispatch tool. It accepts `--tier=worker|expert` and `--backend=`, resolving the model from `OLLIE_TIER_{WORKER|EXPERT}_{backend}` env vars. `cascade` and `escalate` are simpler variants with fixed tier semantics (cheap worker and strong expert respectively).
-
-**Callback protocol**: when a sub-agent finishes, it writes a structured result to the parent's prompt file:
-
-```
-[from={session_id}]
-STATUS: done|error
-SUMMARY:
-```
-
-The parent receives this as its next queued turn — no polling, no blocking, no active waiting.
-
-For heterogeneous parallel delegation (N agents, N different prompts), multiple `route`/`cascade` calls fan out concurrently (tool scripts are annotated `ollie:parallel read`):
-
-```
-call_tool: calls=[
- {tool: "route", args: ["implement X in /src/a.go"]},
- {tool: "route", args: ["implement Y in /src/b.go"]},
- {tool: "route", args: ["redesign Z", "--tier=expert"]}
-]
-```
-
-The architecture boundary is clean: `execute_code` is the only built-in tool in `core`; everything else is a tool script; `s/` is a 9P namespace. No layer knows about the others.
-
-## Typical Consumer Setup
-
-```go
-newDispatcher := tools.NewDispatcherFunc(map[string]func() tools.Server{
- "execute": execute.Decl(cwd), // "" falls back to os.Getwd()
-})
-
-env := agent.BuildAgentEnv(cfg, newDispatcher(), cwd)
-
-core := agent.NewAgentCore(agent.AgentCoreConfig{
- Backend: be,
- CWD: cwd,
- Env: env,
- NewDispatcher: newDispatcher,
- // ...
-})
-```
-
-`BuildAgentEnv` wires together the pre-registered servers with the config. On `/agent` switches, `NewDispatcher` is called to produce a fresh dispatcher — all servers are torn down and recreated for the new agent config. `CWD` is preserved across switches.
-
-## Session Lifecycle
-
-### Per-Agent 9P Principals
-
-Each agent session gets its own FUSE mount at `/tmp/ollie-mount-{sessionID}`, created by `ollie-9p-mount -u {sessionID}`. The session ID is used as the 9P user principal, so the server can enforce per-agent file permissions. The agent's `$OLLIE` env var points to this private mount — not the user's global mount.
-
-This enables 9P permission enforcement: an agent cannot write to its own `prompt` file (preventing self-prompting loops), cannot read other agents' private state unless explicitly permitted, and cannot escalate its own privileges.
-
-### Tmpdir and Cleanup
-
-`NewAgentCore` creates `/tmp/ollie/{sessionID}` when a session starts. `Core.Close()` removes it recursively and tears down the FUSE mount. olliesrv calls `Close()` from `killSession` (individual session removal) and `Shutdown` (server stop — iterates all sessions). `SetSessionID` renames the tmpdir atomically alongside the persisted session files.
-
-`!` shell commands run in the session's current working directory (`Core.CWD()`), not the process working directory.
-
-## Session and Context
-
-`agent.Session` owns the message history as a flat `[]backend.Message` slice. There is no rolling window or automatic eviction — the history grows until compaction
-runs.
-
-**Compaction** runs automatically before a turn when the estimated token count reaches
-75% of the model's context length (defaulting to 128k if the backend doesn't report
-one). It can also be triggered manually via `/compact` (from `s/sh` or the `ctl` file).
-Either way it makes a single LLM call to summarize the conversation, then replaces the
-history with a compact representation:
-
-1. All `system` messages are preserved verbatim.
-2. Recent `user` messages are preserved up to a ~20 000-token budget (newest first,
- estimated at 4 chars/token).
-3. The LLM-generated summary is appended as a `user` message with a handoff prefix
- so the next turn can resume seamlessly.
-
-Tool call/result pairs are flattened to plain text before the compaction request so no
-tool schemas are needed for the summarization call. Before compaction runs, a full
-snapshot of the pre-compaction history is written to
-`{sessionsDir}/{sessionID}.compaction.jsonl` for recovery.
-
-`/context` and `/usage` commands report the current message breakdown and token usage
-without modifying history.
-
-## Sandboxing
-
-Sandboxing is applied specifically to `execute_code`. Each code step is wrapped with
-[landrun](https://github.com/landlock-lsm/landrun) (Landlock LSM) via
-`internal/sandbox`. The sandbox config is loaded by name from
-`~/.config/ollie/sandbox/.yaml`; if no `sandbox` argument is passed in the
-`execute_code` call, the `default` config is used.
-
-**landrun is required.** If landrun is not installed or not in `PATH`,
-`execute_code` will refuse to run and return a hard error. There is no
-unsandboxed fallback.
-
-Steps marked `elevated: true` run outside the sandbox via the elevation backend
-(`x/elevate`) and are the only exception. All other steps are sandboxed unconditionally.
-
-## HTTP Gateway
-
-`ollie-httpgw` is a thin translation layer between HTTP and 9P. It connects to
-`olliesrv` as a 9P client and exposes the same namespace over REST-style HTTP,
-so any HTTP client can interact with ollie without a 9P library.
-
-Each HTTP method maps to a 9P primitive: `GET` → read/stat, `POST` → write,
-`DELETE` → remove, `PATCH` → wstat (rename). The gateway serializes file content
-to JSON where the format is well-defined (key-value files, tab-separated records,
-session index); raw text falls through as-is.
-
-Sensitive internal paths (`tr/`, `help`, `statewait`, `systemprompt`, etc.) are
-blocked at the gateway layer and never exposed over HTTP.
-
-```
-HTTP client (browser, curl, script)
- │ HTTP/JSON :8080
- ▼
- ollie-httpgw
- │ 9P protocol
- ▼
- olliesrv ($NAMESPACE/ollie or tcp:9564)
-```
-
-The gateway can connect to a local server via the Plan 9 namespace (default) or
-to a remote server via TCP (`-net tcp -addr host:9564`), making it equally useful
-for local setups and container/remote deployments.
-
-## Frontends
-
-All frontends interact with the same 9P namespace, either directly or via httpgw:
-
-| Frontend | Interface | Talks to |
-|---|---|---|
-| `s/sh` | terminal (readline) | 9P filesystem directly |
-| ellie | Emacs buffer | 9P filesystem directly |
-| Web UI | browser (Preact SPA) | `ollie-httpgw` over HTTP |
-
-The web UI (`webui/`) is a Preact single-page application. It polls
-`/s/{id}/chat` every two seconds for new output and proxies all API paths to
-`ollie-httpgw`. It has no knowledge of 9P; the gateway is the only boundary.
-
-Frontends that use the filesystem (s/sh, ellie, web
-UI) are decoupled from the implementation entirely — they work equally well
-against a local or remote `olliesrv`.
+1. **9P as the integration layer** — not REST, not gRPC, not a library API. Files are the universal interface.
+2. **Single built-in tool** — `execute_code` is the only compiled-in capability. This keeps the core small and makes all other tools equal citizens.
+3. **Mandatory sandboxing** — no unsandboxed fallback. Security is not optional.
+4. **Streaming-only backends** — simplifies the interface; blocking APIs wrap as single-event streams.
+5. **Fire-and-forget sub-agents** — no active waiting, no polling. Results arrive as filesystem writes.
+6. **Prompt-as-code** — system prompts are assembled by running shell commands, not by concatenating static strings.
+7. **Per-agent principals** — 9P permission enforcement prevents self-prompting loops and enforces access boundaries without application-level checks.
diff --git a/doc/CORE.md b/doc/CORE.md
new file mode 100644
index 0000000..e27dc40
--- /dev/null
+++ b/doc/CORE.md
@@ -0,0 +1,642 @@
+# ollie-core: Detailed Architecture
+
+This document covers the internal design of the `core/` library — the Go package that implements the agent engine. It is the dependency imported by all consumers (olliesrv, frontends, tests).
+
+## Package Map
+
+```
+core/
+├── pkg/
+│ ├── agent/ The agent engine
+│ │ ├── types.go Core interface, Event, PromptFIFO
+│ │ ├── core.go AgentCoreConfig, agent struct, NewAgentCore, BuildAgentEnv
+│ │ ├── loop.go The agent loop (run function)
+│ │ ├── session.go Session, TaskState, compaction, persistence
+│ │ ├── state.go state interface, ResultTier, toolResult
+│ │ ├── hooks.go Hooks map, HookResult, hook execution
+│ │ ├── commands.go Slash-command dispatch (/model, /agent, /compact, ...)
+│ │ ├── cost.go Model pricing table, cost computation
+│ │ └── prompt_resolver.go System prompt assembly from shell commands
+│ ├── backend/ LLM provider implementations
+│ │ ├── backend.go Backend interface, shared types, streamRequest
+│ │ ├── openai.go OpenAI/OpenRouter/compatible APIs
+│ │ ├── anthropic.go Anthropic Messages API
+│ │ └── ollama.go Ollama local inference
+│ ├── config/ Agent config JSON schema and loader
+│ ├── env/ Environment variable loading
+│ ├── log/ Structured logger
+│ ├── paths/ XDG directory resolution
+│ └── tools/ Tool system
+│ ├── tools.go Server, Dispatcher, classifier interfaces
+│ └── execute/ The execute.Server
+│ ├── server.go Server struct, Decl factory, ListTools, options
+│ ├── code.go Step resolution, validation, dispatch routing
+│ ├── pipe.go Pipeline stage execution, parallel fan-out
+│ ├── flock.go Advisory locking, parallelism classification
+│ └── tier.go Result tier classification (hot/warm/cold)
+└── internal/
+ └── sandbox/ Landlock sandbox wrapper
+ ├── config.go YAML config, path expansion, access checking
+ └── wrapper.go landrun command construction
+```
+
+## The Core Interface (`pkg/agent/types.go`)
+
+`Core` is the boundary between the engine and any consumer. It is deliberately narrow:
+
+```go
+type Core interface {
+ // Turn lifecycle
+ Submit(ctx context.Context, input string) // process user input (commands or prompts)
+ Interrupt(cause error) bool // cancel current turn
+ Inject(prompt string) // append to next tool result
+ Queue(prompt string) // FIFO for deferred prompts
+ PopQueue() (string, bool) // drain one queued prompt
+
+ // Observation
+ IsRunning() bool
+ State() string // "idle" | "thinking" | "calling: "
+ Reply() string // last assistant text
+ Bus() *pubsub.Bus // event stream
+ WaitChange(ctx context.Context, field, current string) (string, bool)
+
+ // Configuration
+ AgentName() string
+ BackendName() string
+ ModelName() string
+ CWD() string
+ SetCWD(dir string) error
+ SetSessionID(newID string) error
+ SetEnv(key, value string)
+ SetGenerationParams(params backend.GenerationParams) error
+
+ // Introspection
+ Context() []backend.Message
+ SystemPrompt() string
+ GenerationParams() backend.GenerationParams
+ CtxSz() string
+ Usage() string
+ Cost() string
+ ListModels() string
+ ToolCallCount() int64
+
+ // Cleanup
+ Close()
+}
+```
+
+All output flows through the event bus as `Event{Role, Name, Content}` values. Roles include: `assistant`, `tool`, `call`, `info`, `exec`, `error`, `usage`, `reasoning`, `limitretry`.
+
+## Agent Construction (`pkg/agent/core.go`)
+
+### AgentCoreConfig
+
+The full construction config:
+
+| Field | Purpose |
+|---|---|
+| `Backend` | Active LLM backend instance |
+| `ModelName` | Override backend's default model |
+| `AgentName` | Name of the active agent config |
+| `AgentsDir` | Path to agent JSON directory |
+| `SessionsDir` | Path to persisted session files |
+| `SessionID` | Unique session identifier |
+| `Uname` | Immutable 9P user principal |
+| `CWD` | Working directory for tools and prompt |
+| `Session` | Pre-existing session to restore (nil = new) |
+| `Env` | Pre-built AgentEnv (tools, hooks, prompt, params) |
+| `NewDispatcher` | Factory for fresh Dispatcher on agent switch |
+| `NewBackend` | Factory for backend by name |
+| `Log` | Logger instance |
+| `MaxSteps` | Override agent config's maxSteps |
+
+### BuildAgentEnv
+
+`BuildAgentEnv(cfg, dispatcher, cwd, env)` wires everything together:
+
+1. Lists all tools from the dispatcher
+2. Resolves the system prompt by executing the config's `prompt` commands
+3. Extracts generation parameters from the config
+4. Builds the tool executor closure (routes calls through the dispatcher)
+5. Extracts parallelism and tier classifiers from the execute server
+6. Returns an `AgentEnv` struct ready for `NewAgentCore`
+
+### NewAgentCore
+
+Creates the `agent` struct:
+1. Sweeps stale tmpdirs from previous crashes
+2. Creates `/tmp/ollie/{sessionID}` for this session
+3. Sets up the plan file reader (reads `s/{id}/plan` for the next unchecked step)
+4. Wires the `turnError` hook with a detached context
+5. Pushes `OLLIE_SESSION_ID` and `OLLIE_UNAME` into the execute server env
+6. Sets the flock directory for parallel coordination
+
+## The Agent Loop (`pkg/agent/loop.go`)
+
+The `run()` function is the core execution engine. It takes an `agentConfig` and a `state` interface, and loops until the model stops calling tools.
+
+### Per-Iteration Flow
+
+```
+1. Context gate: strip cold material if over budget
+2. Build message history:
+ - If TaskState exists: system prompt + task state + hot tail only
+ - Otherwise: system prompt + full history
+3. Stream from backend (with retry logic)
+4. If no tool calls: break (turn complete)
+5. Execute tool calls (serial or parallel)
+6. Update state (append messages + results)
+7. Infer TaskState updates from what happened
+8. Safety checks (error limits, stall, replan, max steps)
+9. Auto-compact if needed
+10. Loop
+```
+
+### Streaming & Retry
+
+The streaming loop handles three failure modes:
+
+| Failure | Detection | Recovery |
+|---|---|---|
+| Rate limit (429) | `*RateLimitError` | Exponential backoff (5×2^attempt seconds), up to 3 retries |
+| Transient (5xx, network) | `*TransientError` | Same retry logic |
+| Stream drop | `Done` never received, channel closes | Retry with 2×2^attempt second delay |
+| Context overflow | `*ContextOverflowError` | Trigger compaction, retry |
+| Tool unsupported | `*ToolUnsupportedError` | Propagate to turnError hook |
+
+On the first error, the `turnError` hook fires. If it exits 0 (handled), the loop returns immediately — the hook is responsible for recovery (e.g., switching models via `freeloader`).
+
+### Tool Execution
+
+Tool calls are dispatched with parallelism awareness:
+
+1. **Classify** each tool call via `ClassifyTool(name)` (reads `ollie:parallel` annotation)
+2. **Batch** consecutive parallel-read-safe calls into a group
+3. **Fan out** the batch concurrently with deduplication (identical calls share one execution)
+4. **Serial** calls run one at a time
+
+Per-call lifecycle:
+```
+preTool hook → execute → postTool hook → inject check → truncation → cache (if read-safe)
+```
+
+- `preTool` exit 2 blocks execution
+- `postTool` exit 2 replaces the result
+- Results exceeding `ToolResultMaxBytes` are hard-truncated
+- Read-safe results are cached in a `sync.Map` for the duration of the turn
+
+### Safety Mechanisms
+
+| Mechanism | Threshold | Action |
+|---|---|---|
+| Consecutive errors (soft) | 5 rounds | Inject nudge: "try a different approach" |
+| Consecutive errors (hard) | 10 rounds | Abort the turn |
+| Replan gate | 8 rounds without PLAN: block | Inject replan nudge |
+| Stall detection | 5 rounds with same LastAction | Inject stall nudge |
+| Max steps | Configurable per-agent | Soft exit with nudge |
+| Plan re-injection | Every 10 tool rounds | Re-inject task state |
+
+### TaskState Inference
+
+After each tool round, `inferTaskStateUpdate` updates the structured overlay:
+- `LastAction` ← most recent tool call + truncated result
+- `PlanStep` ← next unchecked item from the plan file (or heuristic from assistant text)
+
+## Session & Context (`pkg/agent/session.go`)
+
+### Message History
+
+`Session` holds a flat `[]backend.Message` slice. No rolling window — grows until compaction.
+
+Token tracking per session:
+- `TotalInputTokens`, `TotalOutputTokens`, `TotalRequests`
+- `TotalCachedInputTokens`, `TotalCacheCreationTokens`
+- Per-turn accumulators reset at turn start
+- `LastTurnCostUSD`, `SessionCostUSD`
+
+### Compaction
+
+Triggered when estimated tokens reach 75% of model context length.
+
+**Three-zone strategy:**
+
+| Zone | Content | Size |
+|---|---|---|
+| Cold | Structured TaskState JSON | 1 message |
+| Warm | One-line decision index | ~10 messages |
+| Hot | Verbatim recent messages | 8 messages |
+
+Process:
+1. Flatten tool call/result pairs to plain text
+2. Send full history + compaction prompt to the LLM
+3. Parse structured JSON response into TaskState
+4. Rebuild history as cold + warm + hot zones
+5. Write pre-compaction snapshot to `{id}.compaction.jsonl` for recovery
+
+### Result Tiers
+
+Tool results are classified at execution time:
+
+| Tier | Behavior | Examples |
+|---|---|---|
+| Hot | Stays verbatim in messages | `file_write`, `file_edit`, `reasoning_think` |
+| Warm | Summarized on next compaction | (custom tools) |
+| Cold | Immediately collapsed to one-line summary | `file_read`, `memory_recall`, `lsp_*` |
+
+Classification sources (in priority order):
+1. Built-in table in `execute/tier.go` (`defaultColdTools`)
+2. `ollie:tier` annotation in the tool script header
+3. Default: hot
+
+### Persistence
+
+Sessions are saved as `PersistedSession` JSON:
+```json
+{
+ "id": "1716300000000-abc123",
+ "agent": "coding",
+ "messages": [...],
+ "taskState": {...}
+}
+```
+
+Saved after every tool round (`SaveSession` callback) and on clean exit.
+
+## Hooks (`pkg/agent/hooks.go`)
+
+Hooks are shell commands executed with:
+- **stdin**: JSON payload with context (session_id, cwd, model, error, etc.)
+- **env**: `OLLIE_*` variables derived from the payload map
+- **timeout**: 60 seconds (configurable in tests)
+- **process group**: `Setpgid=true` for clean kill on timeout
+
+### Exit Code Semantics
+
+| Code | Meaning |
+|---|---|
+| 0 | Success. Stdout appended to context. |
+| 2 | Block. Stops the action (e.g., blocks a tool call, prevents compaction). |
+| Other | Non-blocking warning. Execution continues. |
+
+### Hook Execution Order
+
+Multiple commands per hook run sequentially. If any exits 2, execution stops immediately and returns `Blocked=true`.
+
+## Commands (`pkg/agent/commands.go`)
+
+`Submit()` first checks for commands before starting an agent turn:
+
+| Prefix | Dispatch |
+|---|---|
+| `!` | Shell command in session CWD |
+| `/backend ` | Switch LLM backend |
+| `/model ` | Switch model |
+| `/agent ` | Switch agent config (rebuilds dispatcher) |
+| `/compact` | Manual compaction |
+| `/context` | Show message breakdown |
+| `/usage` | Show token counts |
+| `/cost` | Show cost |
+| `/models` | List available models |
+| `/agents` | List available agents |
+| `/maxsteps [n]` | Get/set max steps |
+| `/i ` | Inject into running turn |
+| `/irw ` | Inject-rewrite |
+
+### Agent Switching
+
+When `/agent ` is issued:
+1. Load the new agent config JSON
+2. Create a fresh dispatcher via `newDispatcher()`
+3. Call `BuildAgentEnv` with the new config
+4. If the config specifies a backend/model, switch those too
+5. Replace dispatcher, hooks, prompt, tools, params on the agent struct
+6. Clear the session (fresh context for the new persona)
+
+## Tool System (`pkg/tools/`)
+
+### Dispatcher
+
+The `Dispatcher` is a simple name→Server router:
+
+```go
+type Dispatcher interface {
+ AddServer(name string, s Server)
+ GetServer(name string) (Server, bool)
+ ListTools() ([]ToolInfo, error)
+ Dispatch(ctx context.Context, server, tool string, args json.RawMessage) (json.RawMessage, error)
+}
+```
+
+`NewDispatcherFunc` returns a factory that creates a fresh dispatcher with all servers instantiated. Called on agent switches to get clean server state.
+
+### Optional Server Interfaces
+
+Servers may implement additional interfaces for runtime configuration:
+
+| Interface | Purpose |
+|---|---|
+| `CWDSetter` | Update working directory |
+| `EnvSetter` | Inject session env vars |
+| `LockDirSetter` | Set flock directory for parallel coordination |
+| `ToolRestrictionSetter` | Restrict available executors/tools |
+| `ParallelClassifier` | Report if a tool is read-safe |
+| `TierClassifier` | Report result retention tier |
+
+### execute.Server (`pkg/tools/execute/`)
+
+The single built-in server exposes three executors:
+
+#### execute_code
+
+Runs inline code steps sequentially (or parallel via `{parallel: [...]}`).
+
+**Input schema:**
+```json
+{
+ "steps": [{"code": "...", "language": "bash"}],
+ "timeout": 30,
+ "sandbox": "default"
+}
+```
+
+**Supported languages:** bash, python3, perl, lua, awk, sed, jq, ed, expect, bc.
+
+#### call_tool
+
+Runs named tool scripts from `OLLIE_TOOLS_PATH`.
+
+**Input schema:**
+```json
+{
+ "calls": [{"tool": "file_read", "args": ["/path"]}],
+ "timeout": 30,
+ "sandbox": "default"
+}
+```
+
+Parallel fan-out is automatic for consecutive calls to `ollie:parallel read` tools.
+
+#### pipe
+
+Sequential pipeline — each stage's stdout feeds the next stage's stdin.
+
+**Input schema:**
+```json
+{
+ "stages": [
+ {"tool": "file_grep", "args": ["TODO", "--path=src/"]},
+ {"code": "wc -l"}
+ ],
+ "timeout": 30
+}
+```
+
+### Code Validation
+
+Before execution, inline code is checked against dangerous patterns:
+
+- **Universal**: `mkfs`, `dd if=/dev/`, `sudo/su`, `/etc/shadow`
+- **Bash**: recursive force-delete of system paths, fork bombs, `/dev/sd` writes, eval injection
+- **Python**: `shutil.rmtree("/")`, subprocess rm -rf, os.remove of system paths
+- **Perl**: system/exec rm -rf, backtick rm, unlink system paths
+- **Lua**: os.execute rm -rf, os.remove system paths
+
+Validation failures are rate-limited: 5 failures within 1 minute triggers a 5-minute block.
+
+### Parallelism & Locking (`execute/flock.go`)
+
+Tool scripts declare their concurrency class via header annotations:
+
+| Annotation | Lock Class | Behavior |
+|---|---|---|
+| `ollie:parallel read` | Read | Shared lock (LOCK_SH); concurrent with other reads |
+| `ollie:parallel write` | Write | Exclusive lock (LOCK_EX); serialized |
+| (none) | Global | Serialized with everything |
+
+Lock files live in `/tmp/ollie/{sessionID}/` and are acquired via `flock(2)`.
+
+### Result Tier Classification (`execute/tier.go`)
+
+Built-in cold tools (results consumed immediately, don't need verbatim retention):
+- `file_read`, `memory_recall`, `web_fetch`, `web_search`
+- `lsp_definition`, `lsp_references`, `lsp_hover`
+
+Custom tools declare their tier via `ollie:tier cold|warm|hot` in the script header.
+
+### Elevation
+
+Steps marked `elevated: true` bypass the sandbox entirely. They are dispatched to the elevation backend (`x/elevate`) which runs outside landrun. Only bash is supported for elevated steps.
+
+### FUSE Mount
+
+When `WithMount()` is set, the execute server spawns `ollie-9p-mount -u {uname}` to create a per-session FUSE mount. This gives the agent its own 9P identity for permission enforcement. The mount path is exposed as `$OLLIE` in subprocess environments.
+
+## Backend System (`pkg/backend/`)
+
+### Shared Types
+
+| Type | Purpose |
+|---|---|
+| `Message` | Conversation turn (role, content, tool_calls, tool_call_id) |
+| `Tool` | Function declaration (name, description, JSON Schema params) |
+| `ToolCall` | Model's request to invoke a function |
+| `StreamEvent` | Incremental streaming delta (content, reasoning, tool_calls, done, usage) |
+| `Usage` | Token counts + optional cost |
+| `GenerationParams` | Unified sampling parameters |
+
+### Error Types
+
+| Error | HTTP Code | Loop Behavior |
+|---|---|---|
+| `RateLimitError` | 429 | Retry with backoff |
+| `TransientError` | 5xx, network | Retry with backoff |
+| `ContextOverflowError` | 400 (specific) | Compact and retry |
+| `ToolUnsupportedError` | 400/422 (specific) | Propagate to turnError hook |
+
+### streamRequest
+
+Shared HTTP streaming infrastructure used by all backends:
+1. Execute the HTTP request
+2. Classify response status into typed errors
+3. Spawn a goroutine that parses SSE/NDJSON into `StreamEvent` channel
+4. Return the channel for the loop to consume
+
+### Backend Implementations
+
+**OllamaBackend** (`/api/chat`):
+- Local inference, no API key
+- Context length from `/api/show` (cached)
+- Model list from `/api/tags`
+- NDJSON streaming
+
+**OpenAIBackend** (`/v1/chat/completions`):
+- Works with OpenAI, OpenRouter, any compatible API
+- Configurable base URL, name, extra headers
+- Context length from model list endpoint (cached)
+- SSE streaming (`data: {...}` lines)
+- Handles both `tool_calls` and legacy `function_call`
+
+**AnthropicBackend** (`/v1/messages`):
+- Extended thinking (reasoning budget via `ThinkingBudget`)
+- Prompt caching (cache_control blocks)
+- 200k context window (hardcoded)
+- SSE streaming with content_block_delta events
+
+## Config System (`pkg/config/`)
+
+### Agent Config Schema
+
+```go
+type Config struct {
+ Hooks map[string]HookCmds // lifecycle callbacks
+ Prompt Prompt // system prompt (string or command array)
+ Backend string // backend override
+ Model string // model override
+ Tools *bool // enable/disable tools (default: true)
+ AllowExecutors []string // restrict executors
+ AllowTools []string // restrict tool scripts
+ MaxTokens int // generation limit
+ MaxCompletionTokens int // OpenAI o-series
+ MaxSteps int // tool rounds per turn
+ Temperature *float64
+ TopP, TopK, MinP, TopA *float64/*int
+ FrequencyPenalty *float64
+ PresencePenalty *float64
+ RepetitionPenalty *float64
+ Reasoning int // Anthropic thinking budget
+ ReasoningEffort string // OpenAI: low/medium/high
+ IncludeReasoning *bool // OpenRouter
+ ResponseFormat string // json_object, text
+ Stop []string // stop sequences
+ Verbosity string // output detail level
+}
+```
+
+### Prompt Type
+
+Dual-mode unmarshaling:
+- **JSON string**: treated as literal text (with `!command`, file path, or env expansion)
+- **JSON array**: each element is a shell command; stdout concatenated
+
+The array form is preferred — it enables composable prompt assembly from template fragments.
+
+### HookCmds Type
+
+Dual-mode: a single string `"cmd"` or an array `["cmd1", "cmd2"]`. Multiple commands run sequentially.
+
+## Prompt Resolution (`pkg/agent/prompt_resolver.go`)
+
+Two paths based on `Prompt.IsExec`:
+
+**Exec mode** (array):
+- Each command runs via `sh -c` with 30s timeout
+- CWD set to session working directory
+- Extra env vars injected (OLLIE_SESSION_ID, OLLIE mount path)
+- Non-zero exit skips that command (no error propagation)
+- All stdout concatenated with newlines
+
+**String mode** (legacy):
+- Environment variables expanded
+- If contains newline: literal text
+- If starts with `!`: execute as shell command
+- If names an existing file: read file contents
+- Otherwise: use as-is
+
+## Cost Tracking (`pkg/agent/cost.go`)
+
+When the backend doesn't report cost directly (only OpenRouter does), cost is computed from a built-in pricing table:
+
+- Matches model name prefix (case-insensitive)
+- Covers Anthropic Claude, OpenAI GPT/o-series, Google Gemini
+- Cached input tokens charged at discount (10% Anthropic, 50% others)
+- Cache creation tokens charged at 125% of input rate
+- Unknown/local models return zero cost
+
+## Sandbox (`internal/sandbox/`)
+
+### Config Format
+
+```yaml
+general:
+ best_effort: true # don't fail if kernel doesn't support all features
+ log_level: "" # landrun log level
+
+filesystem:
+ ro: ["/usr", "/lib", "/etc"] # read-only
+ rox: ["/usr/bin", "/usr/local/bin"] # read + execute
+ rw: ["{CWD}", "{TMPDIR}"] # read + write
+ rwx: [] # read + write + execute
+
+network:
+ enabled: true
+ unrestricted: true # all TCP allowed
+ bind_tcp: [] # specific bind ports
+ connect_tcp: [] # specific connect targets
+
+env: [] # extra environment variables
+
+advanced:
+ ldd: false # resolve shared library paths
+ add_exec: false # add execute to all read paths
+```
+
+### Template Variables
+
+| Variable | Expansion |
+|---|---|
+| `{CWD}` | Session working directory |
+| `{HOME}` | User home directory |
+| `{TMPDIR}` | Temp directory (default: /tmp) |
+| `{XDG_CONFIG_HOME}` | XDG config (default: ~/.config) |
+| `{XDG_DATA_HOME}` | XDG data (default: ~/.local/share) |
+| `{XDG_CACHE_HOME}` | XDG cache (default: ~/.cache) |
+| `{XDG_STATE_HOME}` | XDG state (default: ~/.local/state) |
+| `{XDG_RUNTIME_DIR}` | XDG runtime (default: /run/user/UID) |
+| `{OLLIE_CFG_PATH}` | ollie config directory |
+| `{OLLIE_DATA_PATH}` | ollie data directory |
+
+### Path Validation
+
+`checkPath(cfg, path, write, cwd, getenv)` validates that a given absolute path falls within the sandbox's allowed paths. Symlinks are resolved before checking. Used by file tools to pre-validate paths before execution.
+
+## Event System
+
+The agent communicates exclusively through events published on a `pubsub.Bus`:
+
+```go
+type Event struct {
+ Role string // event type
+ Name string // tool name (for call/tool events)
+ Content string // payload
+}
+```
+
+| Role | When |
+|---|---|
+| `assistant` | Streaming text delta from the model |
+| `reasoning` | Thinking/reasoning delta (wrapped in `` tags) |
+| `call` | Tool call initiated (Name=tool, Content=args JSON) |
+| `tool` | Tool result returned (Name=tool, Content=result) |
+| `info` | Informational message (command output, status) |
+| `exec` | Shell command output (`!cmd`) |
+| `error` | Error message |
+| `usage` | Token usage report (space-separated numbers) |
+| `limitretry` | Rate limit retry initiated |
+
+Consumers subscribe to the `"event"` topic on the bus. The 9P server's session manager subscribes to build the chat log; frontends subscribe to render output.
+
+## Concurrency Model
+
+- **One turn at a time**: `submitMu` serializes `Submit()` calls
+- **Interrupt**: `currentAction` holds a `CancelCauseFunc`; `Interrupt()` calls it
+- **Queue drain**: after each turn completes, queued prompts are drained sequentially
+- **State observation**: `WaitChange()` uses a condition variable (`changeCond`) that broadcasts on any state/usage/cwd change
+- **Tool parallelism**: within a turn, read-safe tools fan out via goroutines with `sync.Map` caching and `flock` coordination
+
+## Session ID Format
+
+```
+-<6-hex-chars>
+```
+
+Lexicographically sortable by creation time. Unique even within the same nanosecond due to the random suffix.
diff --git a/webui b/webui
index 0ca3d38..c61a674 160000
--- a/webui
+++ b/webui
@@ -1 +1 @@
-Subproject commit 0ca3d38ec8cc2bf91cf02432ea0aa3205262293a
+Subproject commit c61a674289a51c63fd6001880470ea96fafba2ef