diff --git a/Cargo.lock b/Cargo.lock
index c1e89c34f0..9998615cd7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -101,7 +101,7 @@ version = "1.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
 dependencies = [
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -112,7 +112,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
 dependencies = [
  "anstyle",
  "once_cell_polyfill",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -455,6 +455,16 @@ version = "0.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e79769241dcd44edf79a732545e8b5cec84c247ac060f5252cd51885d093a8fc"
 
+[[package]]
+name = "bstr"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
 [[package]]
 name = "bumpalo"
 version = "3.20.2"
@@ -849,6 +859,16 @@ dependencies = [
  "crossbeam-utils",
 ]
 
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
 [[package]]
 name = "crossbeam-epoch"
 version = "0.9.18"
@@ -1098,7 +1118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
 dependencies = [
  "libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -1388,6 +1408,19 @@ dependencies = [
  "zeroize",
 ]
 
+[[package]]
+name = "globset"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "log",
+ "regex-automata",
+ "regex-syntax",
+]
+
 [[package]]
 name = "h2"
 version = "0.4.13"
@@ -1629,7 +1662,7 @@ dependencies = [
  "libc",
  "percent-encoding",
  "pin-project-lite",
- "socket2 0.5.10",
+ "socket2 0.6.3",
  "tokio",
  "tower-service",
  "tracing",
@@ -1773,6 +1806,22 @@ dependencies = [
  "icu_properties",
 ]
 
+[[package]]
+name = "ignore"
+version = "0.4.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
+dependencies = [
+ "crossbeam-deque",
+ "globset",
+ "log",
+ "memchr",
+ "regex-automata",
+ "same-file",
+ "walkdir",
+ "winapi-util",
+]
+
 [[package]]
 name = "image"
 version = "0.25.10"
@@ -2287,7 +2336,7 @@ version = "0.50.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
 dependencies = [
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -2658,6 +2707,20 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "process-wrap"
+version = "9.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55"
+dependencies = [
+ "futures",
+ "indexmap",
+ "nix",
+ "tokio",
+ "tracing",
+ "windows 0.62.2",
+]
+
 [[package]]
 name = "pxfm"
 version = "0.1.28"
@@ -2708,7 +2771,7 @@ dependencies = [
  "quinn-udp",
  "rustc-hash",
  "rustls",
- "socket2 0.5.10",
+ "socket2 0.6.3",
  "thiserror 2.0.18",
  "tokio",
  "tracing",
@@ -2746,7 +2809,7 @@ dependencies = [
  "cfg_aliases",
  "libc",
  "once_cell",
- "socket2 0.5.10",
+ "socket2 0.6.3",
  "tracing",
  "windows-sys 0.60.2",
 ]
@@ -3051,12 +3114,14 @@ dependencies = [
  "futures",
  "pastey",
  "pin-project-lite",
+ "process-wrap",
  "rmcp-macros",
  "schemars",
  "serde",
  "serde_json",
  "thiserror 2.0.18",
  "tokio",
+ "tokio-stream",
  "tokio-util",
  "tracing",
 ]
@@ -3155,7 +3220,7 @@ dependencies = [
  "errno",
  "libc",
  "linux-raw-sys",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -3214,7 +3279,7 @@ dependencies = [
  "security-framework",
  "security-framework-sys",
  "webpki-root-certs",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -3567,6 +3632,12 @@ version = "0.3.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
 
+[[package]]
+name = "similar"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
+
 [[package]]
 name = "siphasher"
 version = "1.0.2"
@@ -3611,7 +3682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
 dependencies = [
  "libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -3680,6 +3751,22 @@ dependencies = [
  "tokio",
 ]
 
+[[package]]
+name = "sprout-agent"
+version = "0.1.0"
+dependencies = [
+ "arc-swap",
+ "getrandom 0.2.17",
+ "nix",
+ "reqwest 0.13.2",
+ "rmcp",
+ "serde",
+ "serde_json",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+]
+
 [[package]]
 name = "sprout-audit"
 version = "0.1.0"
@@ -3774,6 +3861,24 @@ dependencies = [
  "uuid",
 ]
 
+[[package]]
+name = "sprout-dev-mcp"
+version = "0.1.0"
+dependencies = [
+ "ignore",
+ "nix",
+ "rmcp",
+ "schemars",
+ "serde",
+ "serde_json",
+ "similar",
+ "tempfile",
+ "tokio",
+ "tokio-util",
+ "tracing",
+ "tracing-subscriber",
+]
+
 [[package]]
 name = "sprout-mcp"
 version = "0.1.0"
@@ -4321,7 +4426,7 @@ dependencies = [
  "ntapi",
  "objc2-core-foundation",
  "objc2-io-kit",
- "windows",
+ "windows 0.61.3",
 ]
 
 [[package]]
@@ -4340,7 +4445,7 @@ dependencies = [
  "getrandom 0.4.2",
  "once_cell",
  "rustix",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -5092,7 +5197,7 @@ version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
 dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -5107,11 +5212,23 @@ version = "0.61.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
 dependencies = [
- "windows-collections",
+ "windows-collections 0.2.0",
  "windows-core 0.61.2",
- "windows-future",
+ "windows-future 0.2.1",
  "windows-link 0.1.3",
- "windows-numerics",
+ "windows-numerics 0.2.0",
+]
+
+[[package]]
+name = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections 0.3.2",
+ "windows-core 0.62.2",
+ "windows-future 0.3.2",
+ "windows-numerics 0.3.1",
 ]
 
 [[package]]
@@ -5123,6 +5240,15 @@ dependencies = [
  "windows-core 0.61.2",
 ]
 
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core 0.62.2",
+]
+
 [[package]]
 name = "windows-core"
 version = "0.61.2"
@@ -5157,7 +5283,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
 dependencies = [
  "windows-core 0.61.2",
  "windows-link 0.1.3",
- "windows-threading",
+ "windows-threading 0.1.0",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core 0.62.2",
+ "windows-link 0.2.1",
+ "windows-threading 0.2.1",
 ]
 
 [[package]]
@@ -5204,6 +5341,16 @@ dependencies = [
  "windows-link 0.1.3",
 ]
 
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core 0.62.2",
+ "windows-link 0.2.1",
+]
+
 [[package]]
 name = "windows-result"
 version = "0.3.4"
@@ -5357,6 +5504,15 @@ dependencies = [
  "windows-link 0.1.3",
 ]
 
+[[package]]
+name = "windows-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
 [[package]]
 name = "windows_aarch64_gnullvm"
 version = "0.42.2"
diff --git a/Cargo.toml b/Cargo.toml
index 31c3e7a868..617282d58a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,7 @@ members = [
     "crates/sprout-audit",
     "crates/sprout-mcp",
     "crates/sprout-acp",
+    "crates/sprout-agent",
     "crates/sprout-proxy",
     "crates/sprout-test-client",
     "crates/sprout-admin",
@@ -21,6 +22,7 @@ members = [
     "crates/git-credential-nostr",
     "crates/git-sign-nostr",
     "crates/sprout-pair-relay",
+    "crates/sprout-dev-mcp",
     "examples/countdown-bot",
 ]
 exclude = ["desktop/src-tauri"]
diff --git a/VISION_AGENT.md b/VISION_AGENT.md
new file mode 100644
index 0000000000..79c1cba88c
--- /dev/null
+++ b/VISION_AGENT.md
@@ -0,0 +1,62 @@
+# Vision: sprout-agent + sprout-dev-mcp
+
+## The Problem
+
+A coding agent should be small enough to hold in your head. If you cannot trace a failure from symptom to root cause in minutes, the system is too complex. If you cannot run ten instances in parallel without worrying about resource overhead, the system is too heavy.
+
+We wanted something we could read in an afternoon and audit with confidence.
+
+## What We Built
+
+Two binaries, two protocols, no coupling between them.
+
+**sprout-agent** is an ACP agent. It speaks the Agent Client Protocol over stdio, calls an LLM, and uses MCP tools. Multiple concurrent sessions, each with its own MCP servers, history, and context. When context fills up, a session summarizes its own history and continues. It works with Zed, JetBrains, sprout-acp, or anything else that speaks ACP.
+
+**sprout-dev-mcp** is an MCP server. It gives any agent a shell and a file editor. Ephemeral processes with process-group kill on every exit path. Bounded output. File edits resolve against the working directory. It works with any agent or client that speaks MCP.
+
+Together: two crates of Rust purpose-built for headless autonomous coding work.
+
+## Why We Built Our Own
+
+**Auditability.** A senior engineer can read both binaries in a sitting. There are no abstractions reserved for future flexibility. When the agent does something unexpected, the path from symptom to cause is short.
+
+**Correctness at the boundary.** ACP compliance is not a checkbox. We report a concrete protocol version. We emit every required notification. We handle cancellation on every path. We kill process trees on timeout. Key safety properties have regression tests that lock them down.
+
+**Composability through standards.** The agent does not know what MCP server it talks to. The MCP server does not know what agent is calling it. They compose through protocols, not imports. Run ten agents behind sprout with different MCP configurations. Swap the LLM provider with one environment variable. Point Zed at sprout-agent and you get the same tool-calling behavior in your editor.
+
+## The Architecture
+
+```
+Any ACP client (Zed, JetBrains, sprout-acp, custom)
+        |
+        | stdio ACP (JSON-RPC 2.0)
+        v
+  sprout-agent (up to 8 concurrent sessions)
+        |
+        | stdio MCP (JSON-RPC 2.0) — one per session
+        v
+  sprout-dev-mcp (or any MCP server)
+        |
+        v
+  shell, str_replace, todo; rg + tree on PATH
+```
+
+Two pipes. Two protocols. Each session gets its own MCP server instances — fully isolated. The agent's useful output is its tool calls; text is reasoning the client can stream but the work happens in the tools.
+
+## Design Principles
+
+- **Minimal.** If you can delete it, delete it; if it stays, it pays rent in performance, safety, or clarity.
+
+- **Hardened.** Zero unsafe. Zero panics. Bounded process lifetime, bounded output sizes, bounded history. Process-group kill on every exit path. File edits resolve against the working directory. The shell runs at the operator's trust level, like bash itself. History validity is maintained on every cancellation path. The system degrades gracefully, with bounded failure modes.
+
+- **Protocol-native.** ACP is the only interface to the agent. MCP is the only interface to the tools. No runtime coupling. No shared state. No custom wire formats.
+
+- **Honest.** The agent is a loop: prompt the LLM, execute tool calls, repeat. When context fills, it hands off to itself. When it cannot proceed, it stops.
+
+## What This Enables
+
+- Multiple concurrent sessions in one process — each with independent MCP servers, history, and context (configurable cap, default 8)
+- Ten agents in parallel behind sprout, each with their own MCP configuration
+- Any ACP client gets a coding agent without a custom adapter
+- Any MCP server gets a capable caller without a custom adapter
+- A codebase small enough to fork, modify, and understand in a day — two crates, no coupling between them
diff --git a/crates/sprout-agent/Cargo.toml b/crates/sprout-agent/Cargo.toml
new file mode 100644
index 0000000000..eebdb3fcf2
--- /dev/null
+++ b/crates/sprout-agent/Cargo.toml
@@ -0,0 +1,39 @@
+[package]
+name = "sprout-agent"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+description = "Minimal, unbreakable ACP-compliant agent. Non-streaming. Tool-calls-as-output."
+readme = "README.md"
+keywords = ["acp", "agent", "llm", "mcp", "minimal"]
+categories = ["command-line-utilities", "web-programming"]
+
+[[bin]]
+name = "sprout-agent"
+path = "src/main.rs"
+
+# Test-only fake MCP server. Built unconditionally because cargo can't gate
+# bins on `cfg(test)`, but it's tiny and only used by integration tests.
+[[bin]]
+name = "fake-mcp"
+path = "tests/bin/fake_mcp.rs"
+
+[dependencies]
+tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-std", "io-util", "sync", "process", "time"] }
+serde = { workspace = true }
+serde_json = { workspace = true }
+reqwest = { workspace = true }
+rmcp = { version = "1", default-features = false, features = ["client", "transport-child-process"] }
+arc-swap = "1"
+getrandom = "0.2"
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
+
+[target.'cfg(unix)'.dependencies]
+nix = { version = "0.31", default-features = false, features = ["signal", "process"] }
+
+[dev-dependencies]
+tokio = { workspace = true, features = ["test-util", "rt-multi-thread", "macros", "io-std", "io-util", "sync", "process", "time", "net"] }
+nix = { version = "0.31", default-features = false, features = ["signal", "process"] }
diff --git a/crates/sprout-agent/README.md b/crates/sprout-agent/README.md
new file mode 100644
index 0000000000..ba29dace75
--- /dev/null
+++ b/crates/sprout-agent/README.md
@@ -0,0 +1,284 @@
+# sprout-agent
+
+> Minimal, unbreakable ACP-compliant LLM agent. Stdio in, tool calls out. Non-streaming. No persistence. No cleverness.
+
+[ACP](https://agentclientprotocol.com) is the Agent Client Protocol — JSON-RPC 2.0 over stdio between a client (Zed, JetBrains, sprout-acp, …) and an agent. [MCP](https://modelcontextprotocol.io) is how the agent talks to its tools.
+
+`sprout-agent` is the agent.
+
+## What It Is
+
+```
+        +--------+   stdio (JSON-RPC 2.0)   +---------------+
+        | client | <----------------------> |  sprout-agent |
+        +--------+        ACP frames        +---------------+
+                                              │            │
+                                              │            │ rmcp (stdio)
+                                              │            ▼
+                                              │       MCP servers
+                                              │       (your tools)
+                                              ▼
+                                            HTTPS
+                                              │
+                                              ▼
+                                  Anthropic Messages API
+                                   or any OpenAI-compat
+                                  (vLLM, llama.cpp, OpenRouter,
+                                   Block Gateway, Ollama, …)
+```
+
+A client sends `session/prompt`. The agent loops: call the LLM → get tool calls → run them via MCP → feed results back → repeat. The loop terminates when the LLM stops asking for tools, the round cap is hit, or the client cancels.
+
+The agent's **output is its tool calls**. Generated text is forwarded to the client as `agent_message_chunk` updates, but the real work happens in the tools. The LLM call is non-streaming — one HTTP POST, one response.
+
+## Quick Start
+
+```bash
+# Build
+cargo build --release -p sprout-agent
+
+# Run against Anthropic
+SPROUT_AGENT_PROVIDER=anthropic \
+ANTHROPIC_API_KEY=sk-ant-... \
+ANTHROPIC_MODEL=claude-sonnet-4-5 \
+  ./target/release/sprout-agent
+
+# Or any OpenAI-compatible endpoint
+SPROUT_AGENT_PROVIDER=openai \
+OPENAI_COMPAT_API_KEY=sk-... \
+OPENAI_COMPAT_MODEL=gpt-5 \
+OPENAI_COMPAT_BASE_URL=https://api.openai.com/v1 \
+  ./target/release/sprout-agent
+```
+
+That's the whole setup. The agent reads JSON-RPC frames from stdin, writes them to stdout, and logs to stderr.
+
+## ACP Transcript
+
+A complete round-trip. Lines starting with `→` are client→agent (stdin); `←` are agent→client (stdout). Each line is one newline-terminated JSON value. Comments are not part of the wire.
+
+```jsonc
+// 1. Handshake.
+→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}
+← {"jsonrpc":"2.0","id":1,"result":{
+    "protocolVersion":1,
+    "agentCapabilities":{
+      "loadSession":false,
+      "promptCapabilities":{"image":false,"audio":false,"embeddedContext":false},
+      "mcpCapabilities":{"http":false,"sse":false}
+    },
+    "agentInfo":{"name":"sprout-agent","version":"0.1.0"}
+  }}
+
+// 2. Open a session. The client passes the MCP servers to spawn.
+→ {"jsonrpc":"2.0","id":2,"method":"session/new","params":{
+    "cwd":"/tmp",
+    "mcpServers":[{"name":"echo","command":"/usr/local/bin/echo-mcp","args":[],"env":[]}]
+  }}
+← {"jsonrpc":"2.0","id":2,"result":{"sessionId":"ses_a1b2c3d4e5f6a7b8"}}
+
+// 3. Prompt. The agent loops until the LLM stops calling tools.
+→ {"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{
+    "sessionId":"ses_a1b2c3d4e5f6a7b8",
+    "prompt":[{"type":"text","text":"echo hello"}]
+  }}
+
+// 4. Agent emits tool_call (status: pending) — visible to the UI.
+← {"jsonrpc":"2.0","method":"session/update","params":{
+    "sessionId":"ses_a1b2c3d4e5f6a7b8",
+    "update":{
+      "sessionUpdate":"tool_call",
+      "toolCallId":"toolu_01XYZ",
+      "title":"echo__say",
+      "kind":"other",
+      "status":"pending",
+      "rawInput":{"text":"hello"}
+    }
+  }}
+
+// 5. Agent moves the call to in_progress, runs the MCP tool, then completed.
+← {"jsonrpc":"2.0","method":"session/update","params":{
+    "sessionId":"ses_a1b2c3d4e5f6a7b8",
+    "update":{"sessionUpdate":"tool_call_update","toolCallId":"toolu_01XYZ","status":"in_progress"}
+  }}
+← {"jsonrpc":"2.0","method":"session/update","params":{
+    "sessionId":"ses_a1b2c3d4e5f6a7b8",
+    "update":{
+      "sessionUpdate":"tool_call_update",
+      "toolCallId":"toolu_01XYZ",
+      "status":"completed",
+      "content":[{"type":"content","content":{"type":"text","text":"hello"}}]
+    }
+  }}
+
+// 8. The model sees the result, decides it's done, and the prompt resolves.
+← {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
+```
+
+That's ACP. Three request methods (`initialize`, `session/new`, `session/prompt`), one inbound notification (`session/cancel`), and three outbound update variants (`agent_message_chunk`, `tool_call`, `tool_call_update`). The full server is hand-rolled in `main.rs`.
+
+## Configuration
+
+Everything is environment variables. No flags, no config files. (We are a subprocess; subprocess config is environment.)
+
+| Variable | Default | Notes |
+|---|---|---|
+| `SPROUT_AGENT_PROVIDER` | — | Required. `anthropic` or `openai`. |
+| `ANTHROPIC_API_KEY` | — | Required when provider=anthropic. |
+| `ANTHROPIC_MODEL` | — | Required when provider=anthropic. |
+| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | |
+| `ANTHROPIC_API_VERSION` | `2023-06-01` | |
+| `OPENAI_COMPAT_API_KEY` | — | Required when provider=openai. |
+| `OPENAI_COMPAT_MODEL` | — | Required when provider=openai. |
+| `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, OpenRouter, Ollama, etc. |
+| `SPROUT_AGENT_SYSTEM_PROMPT` | built-in | Inline system prompt. |
+| `SPROUT_AGENT_SYSTEM_PROMPT_FILE` | — | File path. Mutually exclusive with the above. |
+| `SPROUT_AGENT_MAX_ROUNDS` | `16` | Tool-loop iteration cap. |
+| `SPROUT_AGENT_MAX_OUTPUT_TOKENS` | `4096` | Per LLM call. |
+| `SPROUT_AGENT_LLM_TIMEOUT_SECS` | `120` | |
+| `SPROUT_AGENT_TOOL_TIMEOUT_SECS` | `660` | Per-tool call timeout in seconds |
+| `SPROUT_AGENT_MAX_PARALLEL_TOOLS` | `8` | Max concurrent tool calls per turn (1 = sequential) |
+| `SPROUT_AGENT_MAX_SESSIONS` | `8` | Max concurrent ACP sessions |
+| `SPROUT_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. |
+| `SPROUT_AGENT_MAX_HISTORY_BYTES` | `1048576` | 1 MiB. Old turns are evicted past this. |
+
+
+## Providers
+
+`sprout-agent` speaks two HTTP dialects. Pick with `SPROUT_AGENT_PROVIDER`.
+
+| Provider | `SPROUT_AGENT_PROVIDER` | Endpoint | Tested with |
+|---|---|---|---|
+| Anthropic | `anthropic` | `POST {base}/v1/messages` | claude-sonnet-4-5, claude-opus-4 |
+| OpenAI | `openai` | `POST {base}/chat/completions` | gpt-5, gpt-4o |
+| vLLM | `openai` | OpenAI-compatible endpoint | any tool-calling model |
+| llama.cpp | `openai` | `--api-server` mode | any tool-calling GGUF |
+| Ollama | `openai` | `http://localhost:11434/v1` | llama3.1, qwen2.5-coder |
+| OpenRouter | `openai` | `https://openrouter.ai/api/v1` | anything they route |
+| Block Gateway | `openai` | internal | gpt-5, claude |
+
+The "OpenAI" path is wire-compatible with the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat). If a provider claims OpenAI compatibility and supports `tools` + `tool_choice: auto`, it works here.
+
+`Provider` is a Rust `enum` with one `match` in `Llm::complete`. There is no trait, no `Box<dyn>`, no async-trait. Adding a third provider is a `match` arm and one `body`/`parse` pair in `llm.rs`.
+
+## MCP Servers
+
+The client passes MCP server specs in `session/new`. The agent spawns each one as a stdio subprocess, calls `tools/list`, and merges everything into a single tool catalog the LLM sees. Tool names are namespaced as `server__tool` (double underscore separator). Bare tool names containing `__` are rejected at registration.
+
+Example: a single echo MCP server.
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 2,
+  "method": "session/new",
+  "params": {
+    "cwd": "/work",
+    "mcpServers": [
+      {
+        "name": "echo",
+        "command": "/usr/local/bin/echo-mcp",
+        "args": ["--mode", "stdio"],
+        "env": [
+          { "name": "ECHO_VERBOSE", "value": "1" }
+        ]
+      }
+    ]
+  }
+}
+```
+
+Multiple servers: just add more entries. Tool calls fan out to the right server by namespace prefix.
+
+**Transport: stdio only.** No HTTP, no SSE. We advertise this in `agentCapabilities` (`mcpCapabilities.http: false`, `mcpCapabilities.sse: false`); spec-compliant clients won't ask for what we don't have.
+
+## Security Model
+
+The trust boundary is **the operator who launched the agent**. The harness, MCP server binaries, and API keys are all trusted. Untrusted input — model output, tool results, prompts — is bounded.
+
+| Boundary | Mechanism |
+|---|---|
+| Stdout discipline | Single-consumer `mpsc` channel feeding stdout. No two tasks can interleave bytes. All logs go to stderr. |
+| MCP child env | Whitelist (`PATH`, `HOME`, `TERM`, `LANG`, `LC_ALL`, `TMPDIR`) plus what the client explicitly passes. Your `ANTHROPIC_API_KEY` does not leak into MCP children. |
+| MCP child lifetime | Process group via `setpgid(0,0)` in `pre_exec`. On transport break or shutdown: `killpg(SIGKILL)`. Grandchildren die too. |
+| Server poisoning | After a timeout or transport break, the offending server is marked dead. Future calls trigger a lazy restart with exponential backoff. Other servers keep working. |
+| Frame size | `SPROUT_AGENT_MAX_LINE_BYTES` (default 4 MiB). Oversize → connection killed. |
+| LLM response size | 16 MiB hard cap. Both `Content-Length` precheck and streaming-buffer cap. |
+| Cancellation | `tokio::select! { biased; _ = cancel.changed() => ... }` at every loop boundary. Cancel always wins the race. |
+| Session isolation | Up to 8 concurrent sessions (configurable). One prompt per session at a time. Each session gets its own MCP servers. |
+| `tool_use ↔ tool_result` pairing | Encoded in the type system. Every `ToolCall` and `ToolResult` carries a `provider_id: String` (not `Option`). |
+
+### Bounded Everything
+
+| Limit | Default | Where |
+|---|---|---|
+| Inbound JSON-RPC frame | 4 MiB | `SPROUT_AGENT_MAX_LINE_BYTES` |
+| Single prompt | 1 MiB | `MAX_PROMPT_BYTES` |
+| History window | 1 MiB | `SPROUT_AGENT_MAX_HISTORY_BYTES` |
+| LLM response body | 16 MiB | `MAX_LLM_RESPONSE_BYTES` |
+| LLM error body | 4 KiB | `MAX_LLM_ERROR_BODY_BYTES` |
+| Tool result body | 256 KiB | `MAX_TOOL_RESULT_BYTES` |
+| MCP servers / session | 16 | `MAX_MCP_SERVERS` |
+| Tools / session | 128 | `MAX_TOOLS_PER_SESSION` |
+| Tool description bytes | 1 KiB | `MAX_DESCRIPTION_BYTES` |
+| Tool schema bytes | 4 KiB | `MAX_SCHEMA_BYTES` (oversize → replaced with `{}`) |
+| Tool calls per turn | 64 | `MAX_TOOL_CALLS_PER_TURN` |
+| Loop rounds | 16 | `SPROUT_AGENT_MAX_ROUNDS` |
+| LLM call timeout | 120 s | `SPROUT_AGENT_LLM_TIMEOUT_SECS` |
+| Tool call timeout | 660 s | `SPROUT_AGENT_TOOL_TIMEOUT_SECS` |
+
+## What This Is NOT
+
+A short list, because the answer is mostly "no":
+
+- **Not a framework.** No plugins, no recipes, no slash commands, no modes. MCP servers can participate in agent lifecycle via [hook tools](../../docs/MCP_DRIVEN_HOOKS.md) (`_Stop`, `_PostCompact`), but these are advisory, fail-open, and budget-bounded — not a plugin system.
+- **Not streaming.** One non-streaming HTTP POST per round. The LLM's generated text is forwarded to the client as `agent_message_chunk`, but there is no token-level streaming.
+- **Not persistent.** Everything is in-memory, per-process. No SQLite. When context fills, the agent summarizes its own history and continues (context handoff). No external persistence.
+- **Not an SDK.** This is a binary. The protocol seam is stdin/stdout. Use it from any language.
+- **Not a UI.** No TUI, no web, no notifications. The client renders.
+- **Not authenticated.** API keys come from env. Use systemd, Docker secrets, or a wrapper.
+- **Not networked MCP.** Stdio transport only. No HTTP/SSE MCP transport.
+- **Not load-able.** No `session/load`. We advertise `loadSession: false`.
+- **Not a router.** No agent-to-agent, no fan-out, no orchestration. One model. One loop.
+
+**Concurrency model:**
+
+```
+                  ┌──── reader task ──────────┐
+                  │  (stdin → JSON-RPC → ...) │
+                  │                           │
+   stdin ─────────┤   dispatch                │
+                  │     │                     │
+                  │     ├── initialize        │  (sync reply)
+                  │     ├── session/new       │  (sync reply)
+                  │     ├── session/prompt ───┼─── spawn ──> prompt task
+                  │     │                     │              │
+                  │     ├── session/cancel ───┼─> watch::send│ (biased select wins)
+                  │     │                     │              │
+                  └───────────────────────────┘              │
+                                                             │
+                  ┌── writer task ────────────────┐          │
+   stdout ────────┤  mpsc<WireMsg> consumer       │<─────────┘
+                  │  (the only stdout writer)     │
+                  └───────────────────────────────┘
+```
+
+One reader, one writer, up to 8 concurrent prompt tasks (one per session).
+
+## Building
+
+```bash
+cargo build --release -p sprout-agent
+```
+
+## Testing
+
+```bash
+cargo test -p sprout-agent
+```
+
+Test strategy is **real subprocess, no mocks**:
+
+- **Fake LLM** — `tests/fake_llm.rs` and the helpers in `tests/regressions.rs` spin up a real `tokio::net::TcpListener` on port 0, parse `Content-Length`, and return scripted JSON. No HTTP mocking library.
+- **Fake MCP server** — `tests/bin/fake_mcp.rs` is a separate binary controlled by env vars: `FAKE_MCP_HANG_INIT`, `FAKE_MCP_TOOL_DELAY`, `FAKE_MCP_SPAWN_GRANDCHILD`, etc. Each fault path is a real process being abused.
+- **Regression tests are the changelog.** Each `#[test]` in `regressions.rs` is named for the bug it locks down: `assistant_text_preserved_across_prompts`, `cancel_leaves_history_valid_for_next_prompt`, `mcp_init_timeout_kills_child`, `oversize_line_kills_connection`. Read them in order to learn the protocol's failure modes.
diff --git a/crates/sprout-agent/src/agent.rs b/crates/sprout-agent/src/agent.rs
new file mode 100644
index 0000000000..46397431a7
--- /dev/null
+++ b/crates/sprout-agent/src/agent.rs
@@ -0,0 +1,598 @@
+use std::sync::Arc;
+
+use serde_json::json;
+use tokio::sync::{watch, Semaphore};
+use tokio::task::JoinSet;
+
+use crate::config::{Config, MAX_PROMPT_BYTES, MAX_TOOL_CALLS_PER_TURN, MAX_TOOL_RESULT_BYTES};
+use crate::handoff::HandoffOutcome;
+use crate::llm::Llm;
+use crate::mcp::McpRegistry;
+
+use crate::types::{
+    AgentError, ContentBlock, HistoryItem, ProviderStop, StopReason, ToolCall, ToolResult,
+};
+use crate::wire::{self, WireSender};
+
+const ERROR_REFLECTION_SUFFIX: &str =
+    "\n\n[Reflect] Before retrying, identify the cause and change your approach.";
+
+pub struct RunCtx<'a> {
+    pub cfg: &'a Config,
+    pub session_id: &'a str,
+    pub llm: &'a Llm,
+    pub mcp: &'a Arc<McpRegistry>,
+    pub wire: &'a WireSender,
+    pub cancel: &'a mut watch::Receiver<bool>,
+    pub history: &'a mut Vec<HistoryItem>,
+    pub original_task: &'a mut Option<String>,
+    pub handoff_count: &'a mut usize,
+    /// Cumulative `_Stop` objection count for this session (persists
+    /// across `session/prompt` calls). Once it hits
+    /// `cfg.stop_max_rejections` we stop calling `_Stop` for that
+    /// session — a runaway hook can't burn rejections on every prompt.
+    pub stop_rejections: &'a mut u32,
+}
+
+impl RunCtx<'_> {
+    pub async fn run(&mut self, prompt: Vec<ContentBlock>) -> Result<StopReason, AgentError> {
+        let user_text = prompt_to_text(prompt)?;
+        if user_text.len() > MAX_PROMPT_BYTES {
+            return Err(AgentError::InvalidParams(format!(
+                "prompt: exceeds {MAX_PROMPT_BYTES} bytes"
+            )));
+        }
+        if self.original_task.is_none() {
+            *self.original_task = Some(user_text.clone());
+        }
+        self.history.push(HistoryItem::User(user_text));
+
+        let mut round = 0u32;
+        // Per-prompt latch: only used to detect "LLM said end_turn twice
+        // in a row with no tool calls between" within this single prompt.
+        // The cumulative rejection budget lives on the session.
+        let mut last_was_end_turn = false;
+        loop {
+            if self.cfg.max_rounds > 0 && round >= self.cfg.max_rounds {
+                return Ok(StopReason::MaxTurnRequests);
+            }
+            if *self.cancel.borrow() {
+                return Ok(StopReason::Cancelled);
+            }
+            match self.maybe_handoff().await {
+                HandoffOutcome::Cancelled => return Ok(StopReason::Cancelled),
+                HandoffOutcome::Performed => {}
+                HandoffOutcome::Skipped => {
+                    truncate_history(self.history, self.cfg.max_history_bytes)
+                }
+            }
+
+            let tools = self.mcp.tools();
+            round = round.saturating_add(1);
+            let response = tokio::select! {
+                biased;
+                _ = self.cancel.changed() => return Ok(StopReason::Cancelled),
+                r = self.llm.complete(self.cfg, self.history, &tools) => r?,
+            };
+
+            if !response.text.is_empty() {
+                wire::send(
+                    self.wire,
+                    wire::session_update(
+                        self.session_id,
+                        json!({
+                            "sessionUpdate": "agent_message_chunk",
+                            "content": { "type": "text", "text": &response.text }
+                        }),
+                    ),
+                )
+                .await;
+            }
+
+            if response.tool_calls.is_empty() {
+                if response.stop == ProviderStop::ToolUse {
+                    return Err(AgentError::Llm(
+                        "provider: stop=tool_use but zero tool_calls".into(),
+                    ));
+                }
+                self.history.push(HistoryItem::Assistant {
+                    text: response.text,
+                    tool_calls: Vec::new(),
+                });
+                let stop = map_stop(response.stop);
+                // Only gate genuine end_turn — don't override max_tokens/refusal.
+                if stop == StopReason::EndTurn {
+                    // Consecutive-rejection rule: LLM responded to our last
+                    // objection with no tool calls — accept the end and
+                    // move on rather than loop forever.
+                    if last_was_end_turn {
+                        return Ok(stop);
+                    }
+                    if *self.stop_rejections >= self.cfg.stop_max_rejections {
+                        return Ok(stop);
+                    }
+                    let objections = self
+                        .mcp
+                        .call_hooks(
+                            "_Stop",
+                            &json!({}),
+                            self.cfg.hook_timeout,
+                            &self.cfg.hook_servers,
+                        )
+                        .await;
+                    if !objections.is_empty() {
+                        *self.stop_rejections = self.stop_rejections.saturating_add(1);
+                        last_was_end_turn = true;
+                        push_hook_outputs_as_tool_results(self.history, "_Stop", &objections);
+                        continue;
+                    }
+                }
+                return Ok(stop);
+            }
+
+            let mut calls = response.tool_calls;
+            if calls.len() > MAX_TOOL_CALLS_PER_TURN {
+                tracing::warn!(
+                    "capping tool_calls {} -> {MAX_TOOL_CALLS_PER_TURN}",
+                    calls.len()
+                );
+                calls.truncate(MAX_TOOL_CALLS_PER_TURN);
+            }
+            self.history.push(HistoryItem::Assistant {
+                text: response.text,
+                tool_calls: calls.clone(),
+            });
+
+            // Tool calls executed → reset the consecutive-rejection latch.
+            last_was_end_turn = false;
+
+            if let Some(stop) = self.execute_calls(&calls).await {
+                return Ok(stop);
+            }
+        }
+    }
+
+    /// Unified tool-call execution. Three phases:
+    ///   1. Preflight (sequential): emit `pending`; unknown tools fail fast
+    ///      with a synthetic result. Cancel here fills every still-empty
+    ///      slot as cancelled.
+    ///   2. Execute: spawn runnable calls into a `JoinSet` bounded by a
+    ///      `Semaphore(max_parallel_tools)`. `select!` between cancel and
+    ///      `join_next`. On cancel: close semaphore, drain in-flight tasks
+    ///      (each sends `notifications/cancelled` internally), synthesize
+    ///      cancelled for unfilled slots and emit `failed`.
+    ///   3. Append: push results into history in original call order.
+    ///
+    /// `max_parallel_tools = 1` makes phase 2 effectively sequential
+    /// (one in-flight call at a time via the semaphore). Larger values
+    /// run that many calls concurrently.
+    async fn execute_calls(&mut self, calls: &[ToolCall]) -> Option<StopReason> {
+        let mut results: Vec<Option<ToolResult>> = vec![None; calls.len()];
+        let mut runnable: Vec<usize> = Vec::with_capacity(calls.len());
+
+        for (idx, call) in calls.iter().enumerate() {
+            if *self.cancel.borrow() {
+                for (j, c) in calls.iter().enumerate() {
+                    if results[j].is_none() {
+                        // Calls 0..idx already had `pending` emitted; emit
+                        // a terminal `failed` so the client doesn't see
+                        // them stuck.
+                        if j < idx {
+                            emit_failed(self.wire, self.session_id, c, "cancelled").await;
+                        }
+                        results[j] = Some(synthetic_tool_result(c, "cancelled".into()));
+                    }
+                }
+                self.append_results(calls, &mut results);
+                return Some(StopReason::Cancelled);
+            }
+            emit_pending(self.wire, self.session_id, call).await;
+            // Hook tools (bare name starts with `_`) are invisible to the
+            // LLM and only callable via `call_hooks`. Treat any direct
+            // invocation as if the tool didn't exist.
+            if !self.mcp.has(&call.name) || self.mcp.is_hook(&call.name) {
+                let err = format!("unknown tool: {}", call.name);
+                emit_failed(self.wire, self.session_id, call, &err).await;
+                results[idx] = Some(synthetic_tool_result(call, err));
+                continue;
+            }
+            runnable.push(idx);
+        }
+
+        self.execute_parallel(calls, &runnable, &mut results).await;
+
+        self.append_results(calls, &mut results);
+
+        if *self.cancel.borrow() {
+            Some(StopReason::Cancelled)
+        } else {
+            None
+        }
+    }
+
+    fn append_results(&mut self, calls: &[ToolCall], results: &mut [Option<ToolResult>]) {
+        for (i, call) in calls.iter().enumerate() {
+            let mut result = results[i].take().unwrap_or_else(|| ToolResult {
+                provider_id: call.provider_id.clone(),
+                text: "internal error: missing result".into(),
+                is_error: true,
+            });
+            // On tool error: append a reflection prompt so the LLM
+            // diagnoses the failure before blindly retrying.
+            if result.is_error {
+                result.text.push_str(ERROR_REFLECTION_SUFFIX);
+            }
+            self.history.push(HistoryItem::ToolResult(result));
+        }
+    }
+
+    async fn execute_parallel(
+        &mut self,
+        calls: &[ToolCall],
+        runnable: &[usize],
+        results: &mut [Option<ToolResult>],
+    ) {
+        let limit = self.cfg.max_parallel_tools.max(1);
+        let sem = Arc::new(Semaphore::new(limit));
+        let mut set: JoinSet<(usize, InvokeOutcome)> = JoinSet::new();
+
+        for &i in runnable {
+            let call = calls[i].clone();
+            let mcp = Arc::clone(self.mcp);
+            let wire = self.wire.clone();
+            let session_id = self.session_id.to_owned();
+            let timeout = self.cfg.tool_timeout;
+            let cancel = self.cancel.clone();
+            let sem = Arc::clone(&sem);
+            set.spawn(async move {
+                // Acquire a permit; if the semaphore is closed (cancel),
+                // emit a terminal wire update and skip the call.
+                let _permit = match sem.acquire_owned().await {
+                    Ok(p) => p,
+                    Err(_) => {
+                        emit_failed(&wire, &session_id, &call, "cancelled").await;
+                        return (i, InvokeOutcome::Failed("cancelled".into()));
+                    }
+                };
+                emit_in_progress(&wire, &session_id, &call).await;
+                let outcome = invoke_tool_inner(&mcp, &call, timeout, cancel).await;
+                match &outcome {
+                    InvokeOutcome::Done(result) => {
+                        emit_completed(&wire, &session_id, &call, result).await;
+                    }
+                    InvokeOutcome::Failed(msg) => {
+                        emit_failed(&wire, &session_id, &call, msg).await;
+                    }
+                }
+                (i, outcome)
+            });
+        }
+
+        let mut cancel_rx = self.cancel.clone();
+        let mut cancelled = if *cancel_rx.borrow() {
+            sem.close();
+            true
+        } else {
+            false
+        };
+        while !cancelled {
+            tokio::select! {
+                biased;
+                _ = cancel_rx.changed() => {
+                    // Cancel: stop accepting new permits. Do NOT abort
+                    // tasks — each in-flight `mcp.call` observes the same
+                    // cancel receiver via its internal `select!` and
+                    // returns promptly with an "cancelled" error after
+                    // sending `notifications/cancelled` to the server.
+                    sem.close();
+                    cancelled = true;
+                    break;
+                }
+                next = set.join_next() => {
+                    match next {
+                        Some(Ok((i, outcome))) => {
+                            results[i] = Some(outcome_to_result(&calls[i], outcome));
+                        }
+                        Some(Err(e)) => {
+                            tracing::warn!("tool task join error: {e}");
+                        }
+                        None => break,
+                    }
+                }
+            }
+        }
+
+        // After cancel, drain in-flight tasks. Each task's internal
+        // `do_call` observes the cancel receiver and returns promptly
+        // after sending `notifications/cancelled`. We bound the drain
+        // to avoid hanging if a task is stuck in restart/reconnect.
+        if cancelled {
+            let drain_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
+            loop {
+                match tokio::time::timeout_at(drain_deadline, set.join_next()).await {
+                    Ok(Some(Ok((i, outcome)))) => {
+                        if results[i].is_none() {
+                            results[i] = Some(outcome_to_result(&calls[i], outcome));
+                        }
+                    }
+                    Ok(Some(Err(e))) => {
+                        tracing::warn!("tool task join error (drain): {e}");
+                    }
+                    Ok(None) => break, // all tasks drained
+                    Err(_) => {
+                        // Drain timed out — abort remaining tasks.
+                        set.abort_all();
+                        tracing::warn!("cancel drain timed out; aborting remaining tasks");
+                        break;
+                    }
+                }
+            }
+        }
+
+        // Fill any remaining unfilled runnable slots as cancelled. Tasks
+        // that didn't complete (timed out in drain or never started) need
+        // a terminal wire update so the client doesn't see "pending" forever.
+        for &i in runnable {
+            if results[i].is_none() {
+                results[i] = Some(synthetic_tool_result(&calls[i], "cancelled".into()));
+                emit_failed(self.wire, self.session_id, &calls[i], "cancelled").await;
+            }
+        }
+    }
+}
+
+/// Outcome of invoking a single tool. The wire notification is emitted by
+/// the caller so the spawn loop and the (degenerate, max_parallel=1) path
+/// share the same logic.
+enum InvokeOutcome {
+    Done(ToolResult),
+    Failed(String),
+}
+
+/// Standalone tool invocation. Takes only owned/cloned handles so it can
+/// run inside a spawned task. On timeout, kills the offending MCP server's
+/// process group and marks it dead; the registry's lazy restart handles it
+/// on the next call.
+async fn invoke_tool_inner(
+    mcp: &Arc<McpRegistry>,
+    call: &ToolCall,
+    tool_timeout: std::time::Duration,
+    mut cancel: watch::Receiver<bool>,
+) -> InvokeOutcome {
+    if *cancel.borrow() {
+        return InvokeOutcome::Failed("cancelled".into());
+    }
+    match tokio::time::timeout(
+        tool_timeout,
+        mcp.call(
+            &call.name,
+            &call.provider_id,
+            &call.arguments,
+            MAX_TOOL_RESULT_BYTES,
+            &mut cancel,
+        ),
+    )
+    .await
+    {
+        Ok(Ok(result)) => InvokeOutcome::Done(result),
+        Ok(Err(AgentError::Cancelled)) => InvokeOutcome::Failed("cancelled".into()),
+        Ok(Err(e)) => InvokeOutcome::Failed(e.to_string()),
+        Err(_) => {
+            // If the session was cancelled, the timeout fired because
+            // do_call returned quickly with "cancelled" and the outer
+            // timeout raced. Don't kill a healthy server for that.
+            if *cancel.borrow() {
+                return InvokeOutcome::Failed("cancelled".into());
+            }
+            if let Some(server) = mcp.server_of(&call.name) {
+                mcp.kill_server(server, "tool timeout");
+            }
+            let msg = format!(
+                "tool: timeout after {}s. The command took too long. Try a faster approach.",
+                tool_timeout.as_secs()
+            );
+            InvokeOutcome::Failed(msg)
+        }
+    }
+}
+
+fn outcome_to_result(call: &ToolCall, outcome: InvokeOutcome) -> ToolResult {
+    match outcome {
+        InvokeOutcome::Done(r) => r,
+        InvokeOutcome::Failed(m) => synthetic_tool_result(call, m),
+    }
+}
+
+async fn emit_pending(wire: &WireSender, sid: &str, call: &ToolCall) {
+    wire::send(
+        wire,
+        wire::session_update(
+            sid,
+            json!({
+                "sessionUpdate": "tool_call",
+                "toolCallId": call.provider_id,
+                "title": call.name,
+                "kind": "other",
+                "status": "pending",
+                "rawInput": call.arguments,
+            }),
+        ),
+    )
+    .await;
+}
+
+async fn emit_in_progress(wire: &WireSender, sid: &str, call: &ToolCall) {
+    wire::send(
+        wire,
+        wire::session_update(
+            sid,
+            json!({
+                "sessionUpdate": "tool_call_update",
+                "toolCallId": call.provider_id,
+                "status": "in_progress",
+            }),
+        ),
+    )
+    .await;
+}
+
+async fn emit_completed(wire: &WireSender, sid: &str, call: &ToolCall, result: &ToolResult) {
+    wire::send(
+        wire,
+        wire::session_update(
+            sid,
+            json!({
+                "sessionUpdate": "tool_call_update",
+                "toolCallId": call.provider_id,
+                "status": "completed",
+                "content": [{ "type": "content", "content": { "type": "text", "text": result.text } }],
+                "rawOutput": { "isError": result.is_error },
+            }),
+        ),
+    )
+    .await;
+}
+
+async fn emit_failed(wire: &WireSender, sid: &str, call: &ToolCall, err: &str) {
+    wire::send(
+        wire,
+        wire::session_update(
+            sid,
+            json!({
+                "sessionUpdate": "tool_call_update",
+                "toolCallId": call.provider_id,
+                "status": "failed",
+                "rawOutput": { "error": err },
+            }),
+        ),
+    )
+    .await;
+}
+
+fn prompt_to_text(prompt: Vec<ContentBlock>) -> Result<String, AgentError> {
+    let mut parts = Vec::with_capacity(prompt.len());
+    for block in prompt {
+        match block {
+            ContentBlock::Text { text } => parts.push(text),
+            ContentBlock::ResourceLink { uri } => parts.push(format!("[resource: {uri}]")),
+            ContentBlock::Unsupported => {
+                return Err(AgentError::InvalidParams(
+                    "prompt: unsupported content block (only text and resource_link are advertised)".into(),
+                ));
+            }
+        }
+    }
+    Ok(parts.join("\n"))
+}
+
+/// Format a single hook output as a structured tool-result body.
+///
+/// We emit a JSON object rather than XML-style tags. JSON is unambiguous:
+/// the inner `text` field is escaped, so a malicious hook cannot break
+/// out by including a literal `</hook_output>` (or any other delimiter)
+/// in its output. The LLM still sees the source attribution via the
+/// `hook` and `server` fields.
+fn format_hook_output_body(hook: &str, server: &str, text: &str) -> String {
+    // serde_json::to_string never fails on owned strings.
+    serde_json::to_string(&json!({
+        "hook": hook,
+        "server": server,
+        "text": text,
+    }))
+    .unwrap_or_else(|_| String::from("{\"hook\":\"\",\"server\":\"\",\"text\":\"\"}"))
+}
+
+/// Synthetic provider id for an injected hook tool-call/result pair. Must
+/// be unique per pair so the LLM wire format (which keys tool results by
+/// id) stays valid across multiple objections in one session.
+fn synthetic_hook_id(hook: &str, server: &str, ordinal: u64) -> String {
+    format!("sprout_hook_{hook}_{server}_{ordinal}")
+}
+
+/// Append a synthetic Assistant tool-call + ToolResult pair for each hook
+/// output. Modeling hook output as a tool result (rather than as a User
+/// message) means a malicious hook can't impersonate the user or system
+/// — the LLM treats tool results as lower-trust, structured data.
+///
+/// Each pair uses the hook's qualified tool name (e.g. `fake___Stop`) so
+/// attribution is preserved in the wire format. Empty arguments are sent
+/// as `{}`. The `Assistant` turn carries no text (tool_calls only).
+pub(crate) fn push_hook_outputs_as_tool_results(
+    history: &mut Vec<HistoryItem>,
+    hook: &str,
+    outputs: &[(String, String)],
+) {
+    for (server, text) in outputs.iter() {
+        let provider_id = synthetic_hook_id(hook, server, unique_nonce());
+        // Tool name is `<server>__<hook>` — same shape as a real qname
+        // for that hook, so the LLM never sees an unknown synthetic name.
+        let tool_name = format!("{server}__{hook}");
+        history.push(HistoryItem::Assistant {
+            text: String::new(),
+            tool_calls: vec![ToolCall {
+                provider_id: provider_id.clone(),
+                name: tool_name,
+                arguments: serde_json::json!({}),
+            }],
+        });
+        history.push(HistoryItem::ToolResult(ToolResult {
+            provider_id,
+            text: format_hook_output_body(hook, server, text),
+            is_error: false,
+        }));
+    }
+}
+
+/// Monotonic counter for synthetic hook ids within a single process. The
+/// uniqueness target is "no collision within the lifetime of one history
+/// vec", which a process-wide counter satisfies trivially.
+fn unique_nonce() -> u64 {
+    use std::sync::atomic::{AtomicU64, Ordering};
+    static COUNTER: AtomicU64 = AtomicU64::new(0);
+    COUNTER.fetch_add(1, Ordering::Relaxed)
+}
+
+fn synthetic_tool_result(call: &ToolCall, msg: String) -> ToolResult {
+    ToolResult {
+        provider_id: call.provider_id.clone(),
+        text: msg,
+        is_error: true,
+    }
+}
+
+pub(crate) fn truncate_history(history: &mut Vec<HistoryItem>, max_bytes: usize) {
+    let mut total: usize = history.iter().map(HistoryItem::estimated_bytes).sum();
+    if total <= max_bytes {
+        return;
+    }
+    let original_len = history.len();
+    while total > max_bytes && !history.is_empty() {
+        let mut end = 1usize;
+        while end < history.len() && !matches!(history[end], HistoryItem::User(_)) {
+            end += 1;
+        }
+        if end >= history.len() {
+            break;
+        }
+        let dropped: usize = history[..end]
+            .iter()
+            .map(HistoryItem::estimated_bytes)
+            .sum();
+        history.drain(..end);
+        total = total.saturating_sub(dropped);
+    }
+    if history.len() < original_len {
+        tracing::info!(
+            "history truncated {original_len} -> {} items ({total} bytes)",
+            history.len()
+        );
+    }
+}
+
+fn map_stop(p: ProviderStop) -> StopReason {
+    match p {
+        ProviderStop::EndTurn | ProviderStop::ToolUse | ProviderStop::Other => StopReason::EndTurn,
+        ProviderStop::MaxTokens => StopReason::MaxTokens,
+        ProviderStop::Refusal => StopReason::Refusal,
+    }
+}
diff --git a/crates/sprout-agent/src/config.rs b/crates/sprout-agent/src/config.rs
new file mode 100644
index 0000000000..91bbf169c8
--- /dev/null
+++ b/crates/sprout-agent/src/config.rs
@@ -0,0 +1,340 @@
+use std::time::Duration;
+
+pub const PROTOCOL_VERSION: u32 = 1;
+
+pub const MAX_PROMPT_BYTES: usize = 1024 * 1024;
+pub const MAX_TOOL_RESULT_BYTES: usize = 256 * 1024;
+pub const MAX_TOOL_CALLS_PER_TURN: usize = 64;
+
+/// Leaves headroom for the summary call.
+pub const HANDOFF_THRESHOLD: f64 = 0.75;
+
+pub const HANDOFF_MAX_OUTPUT_TOKENS: u32 = 8192;
+
+pub const HANDOFF_TAIL_ITEMS: usize = 5;
+
+pub const HANDOFF_ORIGINAL_TASK_MAX_BYTES: usize = 16 * 1024;
+
+pub const HANDOFF_PROMPT_MAX_BYTES: usize = 32 * 1024;
+
+pub const HANDOFF_MAX_TOOL_NAMES: usize = 20;
+
+const DEFAULT_SYSTEM_PROMPT: &str =
+    "You are sprout-agent. Use the provided tools to act. Tool calls are your only output.";
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Provider {
+    Anthropic,
+    OpenAi,
+}
+
+#[derive(Debug, Clone)]
+pub struct Config {
+    pub provider: Provider,
+    pub system_prompt: String,
+    pub max_rounds: u32,
+    pub max_output_tokens: u32,
+    pub llm_timeout: Duration,
+    pub tool_timeout: Duration,
+    pub mcp_init_timeout: Duration,
+    pub mcp_max_restart_attempts: u32,
+    pub mcp_restart_base_ms: u64,
+    pub mcp_restart_max_ms: u64,
+    pub max_sessions: usize,
+    pub max_line_bytes: usize,
+    pub max_history_bytes: usize,
+    pub max_handoffs: usize,
+    pub max_parallel_tools: usize,
+    pub hook_timeout: Duration,
+    /// Maximum `_Stop` rejections per session. Default 3. Set to 0 to
+    /// disable `_Stop` hooks entirely (agent always honors end_turn).
+    pub stop_max_rejections: u32,
+    /// Hook server allowlist. See [`HookServers`] for variant semantics.
+    /// Default (env unset/empty) is `None` — hooks are off unless the
+    /// operator explicitly opts in.
+    pub hook_servers: HookServers,
+    pub api_key: String,
+    pub model: String,
+    pub base_url: String,
+    pub anthropic_api_version: String,
+}
+
+impl Config {
+    pub fn from_env() -> Result<Self, String> {
+        let provider = match req("SPROUT_AGENT_PROVIDER")?.to_ascii_lowercase().as_str() {
+            "anthropic" => Provider::Anthropic,
+            "openai" | "openai-compat" => Provider::OpenAi,
+            o => return Err(format!("config: SPROUT_AGENT_PROVIDER={o} not supported")),
+        };
+        let (api_key, model, base_url) = match provider {
+            Provider::Anthropic => (
+                req("ANTHROPIC_API_KEY")?,
+                req("ANTHROPIC_MODEL")?,
+                env_or("ANTHROPIC_BASE_URL", "https://api.anthropic.com"),
+            ),
+            Provider::OpenAi => (
+                req("OPENAI_COMPAT_API_KEY")?,
+                req("OPENAI_COMPAT_MODEL")?,
+                env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"),
+            ),
+        };
+        let system_prompt = match (env("SPROUT_AGENT_SYSTEM_PROMPT"), env("SPROUT_AGENT_SYSTEM_PROMPT_FILE")) {
+            (Some(_), Some(_)) => return Err(
+                "config: SPROUT_AGENT_SYSTEM_PROMPT and SPROUT_AGENT_SYSTEM_PROMPT_FILE are mutually exclusive".into()),
+            (Some(s), _) => s,
+            (_, Some(p)) => std::fs::read_to_string(&p).map_err(|e| format!("config: read {p}: {e}"))?,
+            _ => DEFAULT_SYSTEM_PROMPT.to_owned(),
+        };
+        let cfg = Config {
+            provider,
+            system_prompt,
+            api_key,
+            model,
+            base_url,
+            anthropic_api_version: env_or("ANTHROPIC_API_VERSION", "2023-06-01"),
+            max_rounds: parse_env("SPROUT_AGENT_MAX_ROUNDS", 16)?,
+            max_output_tokens: parse_env("SPROUT_AGENT_MAX_OUTPUT_TOKENS", 4096)?,
+            llm_timeout: Duration::from_secs(parse_env("SPROUT_AGENT_LLM_TIMEOUT_SECS", 120)?),
+            tool_timeout: Duration::from_secs(parse_env("SPROUT_AGENT_TOOL_TIMEOUT_SECS", 660)?),
+            mcp_init_timeout: Duration::from_secs(parse_env(
+                "SPROUT_AGENT_MCP_INIT_TIMEOUT_SECS",
+                30,
+            )?),
+            mcp_max_restart_attempts: parse_env("SPROUT_AGENT_MCP_RESTART_MAX_ATTEMPTS", 3u32)?,
+            mcp_restart_base_ms: parse_env("SPROUT_AGENT_MCP_RESTART_BASE_MS", 500u64)?,
+            mcp_restart_max_ms: parse_env("SPROUT_AGENT_MCP_RESTART_MAX_MS", 30_000u64)?,
+            max_sessions: parse_env("SPROUT_AGENT_MAX_SESSIONS", 8)?,
+            max_line_bytes: parse_env("SPROUT_AGENT_MAX_LINE_BYTES", 4 * 1024 * 1024)?,
+            max_history_bytes: parse_env("SPROUT_AGENT_MAX_HISTORY_BYTES", 1024 * 1024)?,
+            max_handoffs: parse_env("SPROUT_AGENT_MAX_HANDOFFS", 5)?,
+            max_parallel_tools: parse_env("SPROUT_AGENT_MAX_PARALLEL_TOOLS", 8usize)?,
+            hook_timeout: Duration::from_millis(parse_env(
+                "SPROUT_AGENT_HOOK_TIMEOUT_MS",
+                2500u64,
+            )?),
+            stop_max_rejections: parse_env("SPROUT_AGENT_STOP_MAX_REJECTIONS", 3u32)?,
+            hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"),
+        };
+        cfg.validate()?;
+        Ok(cfg)
+    }
+
+    fn validate(&self) -> Result<(), String> {
+        const MIN_HISTORY_BYTES: usize = 4096;
+        const MIN_LINE_BYTES: usize = 1024;
+        const MIN_TIMEOUT: Duration = Duration::from_secs(1);
+
+        if self.max_output_tokens < 1 {
+            return Err("config: SPROUT_AGENT_MAX_OUTPUT_TOKENS must be >= 1".into());
+        }
+        if self.max_history_bytes < MIN_HISTORY_BYTES {
+            return Err(format!(
+                "config: SPROUT_AGENT_MAX_HISTORY_BYTES must be >= {MIN_HISTORY_BYTES}"
+            ));
+        }
+        if self.max_history_bytes < MAX_PROMPT_BYTES {
+            return Err(format!(
+                "config: SPROUT_AGENT_MAX_HISTORY_BYTES ({}) must be >= MAX_PROMPT_BYTES ({MAX_PROMPT_BYTES})",
+                self.max_history_bytes
+            ));
+        }
+        if self.max_line_bytes < MIN_LINE_BYTES {
+            return Err(format!(
+                "config: SPROUT_AGENT_MAX_LINE_BYTES must be >= {MIN_LINE_BYTES}"
+            ));
+        }
+        if self.llm_timeout < MIN_TIMEOUT {
+            return Err("config: SPROUT_AGENT_LLM_TIMEOUT_SECS must be >= 1".into());
+        }
+        if self.tool_timeout < MIN_TIMEOUT {
+            return Err("config: SPROUT_AGENT_TOOL_TIMEOUT_SECS must be >= 1".into());
+        }
+        if self.mcp_init_timeout < MIN_TIMEOUT {
+            return Err("config: SPROUT_AGENT_MCP_INIT_TIMEOUT_SECS must be >= 1".into());
+        }
+        if self.max_parallel_tools < 1 {
+            return Err("config: SPROUT_AGENT_MAX_PARALLEL_TOOLS must be >= 1".into());
+        }
+        if self.mcp_max_restart_attempts < 1 {
+            return Err("config: SPROUT_AGENT_MCP_RESTART_MAX_ATTEMPTS must be >= 1".into());
+        }
+        if self.mcp_restart_base_ms < 1 {
+            return Err("config: SPROUT_AGENT_MCP_RESTART_BASE_MS must be >= 1".into());
+        }
+        if self.mcp_restart_max_ms < self.mcp_restart_base_ms {
+            return Err(
+                "config: SPROUT_AGENT_MCP_RESTART_MAX_MS must be >= SPROUT_AGENT_MCP_RESTART_BASE_MS".into(),
+            );
+        }
+        Ok(())
+    }
+}
+
+fn env(k: &str) -> Option<String> {
+    std::env::var(k).ok()
+}
+
+fn env_or(k: &str, d: &str) -> String {
+    env(k).unwrap_or_else(|| d.into())
+}
+
+fn req(k: &str) -> Result<String, String> {
+    env(k).ok_or_else(|| format!("config: {k} required"))
+}
+
+fn parse_env<T: std::str::FromStr>(key: &str, default: T) -> Result<T, String>
+where
+    T::Err: std::fmt::Display,
+{
+    env(key)
+        .map(|v| v.parse().map_err(|e| format!("config: {key}: {e}")))
+        .unwrap_or(Ok(default))
+}
+
+/// Hook-server allowlist parsed from a comma-separated env var.
+///   - unset / empty / whitespace-only → `None` (no hooks enabled)
+///   - `*`                              → `All` (every server eligible)
+///   - `a,b,c`                          → `Only(["a","b","c"])`
+#[derive(Debug, Clone)]
+pub enum HookServers {
+    None,
+    All,
+    Only(Vec<String>),
+}
+
+impl HookServers {
+    /// Returns true iff `name` may receive hook calls.
+    pub fn allows(&self, name: &str) -> bool {
+        match self {
+            HookServers::None => false,
+            HookServers::All => true,
+            HookServers::Only(v) => v.iter().any(|s| s == name),
+        }
+    }
+
+    /// True if no hooks should ever fire — used to short-circuit dispatch.
+    pub fn is_disabled(&self) -> bool {
+        matches!(self, HookServers::None)
+    }
+}
+
+fn parse_hook_servers_env(key: &str) -> HookServers {
+    parse_hook_servers(env(key).as_deref())
+}
+
+/// Pure parser exposed for unit tests. `None` (env unset) and `Some("")`
+/// (env set but empty) both yield `HookServers::None`.
+fn parse_hook_servers(raw: Option<&str>) -> HookServers {
+    let raw = match raw {
+        Some(v) => v,
+        None => return HookServers::None,
+    };
+    let names: Vec<String> = raw
+        .split(',')
+        .map(|s| s.trim().to_owned())
+        .filter(|s| !s.is_empty())
+        .collect();
+    if names.is_empty() {
+        return HookServers::None;
+    }
+    // `*` is the wildcard — only honored when it's the sole entry. A mixed
+    // value like "*,foo" falls through to `Only(["*","foo"])`; "*" is not a
+    // legal MCP server name (it can't pass `valid_name`), so it never matches
+    // an actual server. This avoids silently widening scope on typos.
+    if names.len() == 1 && names[0] == "*" {
+        return HookServers::All;
+    }
+    HookServers::Only(names)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn hook_servers_unset_is_none() {
+        assert!(matches!(parse_hook_servers(None), HookServers::None));
+    }
+
+    #[test]
+    fn hook_servers_empty_string_is_none() {
+        assert!(matches!(parse_hook_servers(Some("")), HookServers::None));
+    }
+
+    #[test]
+    fn hook_servers_whitespace_only_is_none() {
+        assert!(matches!(
+            parse_hook_servers(Some("   ,, ,")),
+            HookServers::None
+        ));
+    }
+
+    #[test]
+    fn hook_servers_star_is_all() {
+        assert!(matches!(parse_hook_servers(Some("*")), HookServers::All));
+    }
+
+    #[test]
+    fn hook_servers_star_with_whitespace_is_all() {
+        assert!(matches!(
+            parse_hook_servers(Some("  *  ")),
+            HookServers::All
+        ));
+    }
+
+    #[test]
+    fn hook_servers_named_list() {
+        match parse_hook_servers(Some("foo,bar")) {
+            HookServers::Only(v) => assert_eq!(v, vec!["foo".to_owned(), "bar".to_owned()]),
+            other => panic!("expected Only, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn hook_servers_trims_entries() {
+        match parse_hook_servers(Some(" foo , bar , ")) {
+            HookServers::Only(v) => assert_eq!(v, vec!["foo".to_owned(), "bar".to_owned()]),
+            other => panic!("expected Only, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn hook_servers_star_mixed_is_literal() {
+        // `*,foo` is NOT a wildcard — it's a literal Only(["*","foo"]).
+        // No real server can be named `*`, so this never matches anything.
+        match parse_hook_servers(Some("*,foo")) {
+            HookServers::Only(v) => assert_eq!(v, vec!["*".to_owned(), "foo".to_owned()]),
+            other => panic!("expected Only, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn hook_servers_allows_matches_named_only() {
+        let hs = parse_hook_servers(Some("foo,bar"));
+        assert!(hs.allows("foo"));
+        assert!(hs.allows("bar"));
+        assert!(!hs.allows("baz"));
+    }
+
+    #[test]
+    fn hook_servers_allows_matches_all() {
+        assert!(parse_hook_servers(Some("*")).allows("anything"));
+    }
+
+    #[test]
+    fn hook_servers_allows_blocks_when_none() {
+        assert!(!parse_hook_servers(None).allows("foo"));
+    }
+
+    #[test]
+    fn hook_servers_star_mixed_does_not_match_real_server() {
+        let hs = parse_hook_servers(Some("*,foo"));
+        // The literal "*" entry exists in Only, but no real server can
+        // be named "*" (rejected by the MCP server name validator).
+        assert!(hs.allows("foo"));
+        assert!(!hs.allows("bar"));
+        // Allowed strictly only as a literal match — defense-in-depth
+        // expectation for callers.
+        assert!(hs.allows("*"));
+    }
+}
diff --git a/crates/sprout-agent/src/handoff.rs b/crates/sprout-agent/src/handoff.rs
new file mode 100644
index 0000000000..4cb47e7013
--- /dev/null
+++ b/crates/sprout-agent/src/handoff.rs
@@ -0,0 +1,210 @@
+use crate::agent::{push_hook_outputs_as_tool_results, RunCtx};
+use crate::config::{
+    HANDOFF_MAX_OUTPUT_TOKENS, HANDOFF_MAX_TOOL_NAMES, HANDOFF_ORIGINAL_TASK_MAX_BYTES,
+    HANDOFF_PROMPT_MAX_BYTES, HANDOFF_TAIL_ITEMS, HANDOFF_THRESHOLD,
+};
+use crate::types::HistoryItem;
+
+pub(crate) enum HandoffOutcome {
+    Performed,
+    Skipped,
+    Cancelled,
+}
+
+const HANDOFF_SYSTEM_PROMPT: &str = "You are generating a context handoff summary for the next \
+turn of an autonomous agent. Be concise but thorough. Cover: what the original task was, what \
+you accomplished, key decisions made, what remains, and one concrete next step. Output plain \
+text only — no tool calls, no JSON. Stay under 8192 tokens.";
+
+const HANDOFF_SNIPPET_BYTES: usize = 2048;
+
+impl RunCtx<'_> {
+    pub(crate) async fn maybe_handoff(&mut self) -> HandoffOutcome {
+        if !self.should_handoff() {
+            return HandoffOutcome::Skipped;
+        }
+        if *self.handoff_count >= self.cfg.max_handoffs {
+            tracing::info!(
+                "handoff cap reached ({}); using truncation",
+                self.cfg.max_handoffs
+            );
+            return HandoffOutcome::Skipped;
+        }
+        let prompt = self.build_handoff_prompt();
+        let summary = tokio::select! {
+            biased;
+            _ = self.cancel.changed() => return HandoffOutcome::Cancelled,
+            r = self.llm.summarize(
+                self.cfg,
+                HANDOFF_SYSTEM_PROMPT,
+                &prompt,
+                HANDOFF_MAX_OUTPUT_TOKENS,
+            ) => match r {
+                Ok(s) if !s.trim().is_empty() => s,
+                Ok(_) => {
+                    tracing::warn!("handoff returned empty summary; truncating");
+                    return HandoffOutcome::Skipped;
+                }
+                Err(e) => {
+                    tracing::warn!("handoff failed: {e}; truncating");
+                    return HandoffOutcome::Skipped;
+                }
+            },
+        };
+        let current_prompt = self.history.iter().rev().find_map(|item| match item {
+            HistoryItem::User(s) => Some(s.clone()),
+            _ => None,
+        });
+        let prior = self.history.len();
+        // Reset history first; the _PostCompact hook is meant to inject
+        // state into the FRESH context, not the old one we're discarding.
+        self.history.clear();
+        let post_compact = self
+            .mcp
+            .call_hooks(
+                "_PostCompact",
+                &serde_json::json!({}),
+                self.cfg.hook_timeout,
+                &self.cfg.hook_servers,
+            )
+            .await;
+        // Handoff summary is trusted (we generated it). Push as User so
+        // it anchors the new context.
+        let handoff_text = format!("[Context Handoff]\n{summary}");
+        self.history.push(HistoryItem::User(handoff_text));
+        // Hook output is untrusted — inject as synthetic tool results so a
+        // malicious _PostCompact can't impersonate the user/system.
+        if !post_compact.is_empty() {
+            push_hook_outputs_as_tool_results(self.history, "_PostCompact", &post_compact);
+        }
+        if let Some(prompt) = current_prompt {
+            self.history.push(HistoryItem::User(prompt));
+        }
+        *self.handoff_count += 1;
+        tracing::info!(
+            "handoff #{} (history {prior} -> {} items)",
+            *self.handoff_count,
+            self.history.len()
+        );
+        HandoffOutcome::Performed
+    }
+
+    fn should_handoff(&self) -> bool {
+        let usage: usize = self.history.iter().map(HistoryItem::estimated_bytes).sum();
+        let threshold = (self.cfg.max_history_bytes as f64 * HANDOFF_THRESHOLD) as usize;
+        usage > threshold
+    }
+
+    fn build_handoff_prompt(&self) -> String {
+        let mut head = String::new();
+        head.push_str(&format!(
+            "[Internal handoff #{} — context reset]\n\n",
+            *self.handoff_count + 1
+        ));
+        head.push_str("# Original Task\n");
+        let task = self.original_task.as_deref().unwrap_or("(unknown)");
+        head.push_str(&clamp_bytes(task, HANDOFF_ORIGINAL_TASK_MAX_BYTES));
+        head.push_str("\n\n# Available Tools\n");
+        let all_tools = self.mcp.tools();
+        let total = all_tools.len();
+        if total == 0 {
+            head.push_str("(none)\n");
+        } else {
+            let shown = total.min(HANDOFF_MAX_TOOL_NAMES);
+            let names: Vec<&str> = all_tools[..shown].iter().map(|t| t.name.as_str()).collect();
+            head.push_str(&names.join(", "));
+            if shown < total {
+                head.push_str(&format!(", … (+{} more)", total - shown));
+            }
+            head.push('\n');
+        }
+        let tail = "\n# Instructions\n\
+             Produce a context handoff summary covering: (1) original task, \
+             (2) what was accomplished, (3) key decisions, (4) what remains, \
+             (5) one concrete next step. Be concise but thorough. Plain text.\n";
+        let history_header = "\n# Recent History (most recent last)\n";
+
+        let start = self.history.len().saturating_sub(HANDOFF_TAIL_ITEMS);
+        let mut snippets: Vec<String> = self.history[start..]
+            .iter()
+            .map(|item| {
+                let mut s = String::new();
+                push_history_snippet(&mut s, item);
+                s
+            })
+            .collect();
+
+        let fixed = head.len() + history_header.len() + tail.len();
+        let mut snippets_bytes: usize = snippets.iter().map(String::len).sum();
+        let mut dropped = 0usize;
+        while fixed + snippets_bytes > HANDOFF_PROMPT_MAX_BYTES && !snippets.is_empty() {
+            let removed = snippets.remove(0);
+            snippets_bytes -= removed.len();
+            dropped += 1;
+        }
+        if dropped > 0 {
+            tracing::info!("handoff prompt cap, dropped {dropped} oldest snippets");
+        }
+
+        let mut out =
+            String::with_capacity(fixed + snippets_bytes + if dropped > 0 { 32 } else { 0 });
+        out.push_str(&head);
+        out.push_str(history_header);
+        if dropped > 0 {
+            out.push_str(&format!("(… {dropped} older items omitted)\n"));
+        }
+        for s in &snippets {
+            out.push_str(s);
+        }
+        out.push_str(tail);
+        out
+    }
+}
+
+fn push_history_snippet(out: &mut String, item: &HistoryItem) {
+    match item {
+        HistoryItem::User(s) => {
+            out.push_str("[user] ");
+            out.push_str(&clamp_for_snippet(s));
+            out.push('\n');
+        }
+        HistoryItem::Assistant { text, tool_calls } => {
+            out.push_str("[assistant] ");
+            if !text.is_empty() {
+                out.push_str(&clamp_for_snippet(text));
+            }
+            for c in tool_calls {
+                out.push_str(&format!(" tool:{}", c.name));
+            }
+            out.push('\n');
+        }
+        HistoryItem::ToolResult(r) => {
+            out.push_str(if r.is_error { "[tool_err] " } else { "[tool] " });
+            out.push_str(&clamp_for_snippet(&r.text));
+            out.push('\n');
+        }
+    }
+}
+
+fn clamp_for_snippet(s: &str) -> String {
+    clamp_bytes(s, HANDOFF_SNIPPET_BYTES)
+}
+
+pub(crate) fn clamp_bytes(s: &str, max_bytes: usize) -> String {
+    if s.len() <= max_bytes {
+        return s.to_owned();
+    }
+    if max_bytes < 4 {
+        let mut cut = max_bytes.min(s.len());
+        while cut > 0 && !s.is_char_boundary(cut) {
+            cut -= 1;
+        }
+        return s[..cut].to_owned();
+    }
+    let target = max_bytes - "…".len();
+    let mut cut = target;
+    while cut > 0 && !s.is_char_boundary(cut) {
+        cut -= 1;
+    }
+    format!("{}…", &s[..cut])
+}
diff --git a/crates/sprout-agent/src/llm.rs b/crates/sprout-agent/src/llm.rs
new file mode 100644
index 0000000000..3fc8abae75
--- /dev/null
+++ b/crates/sprout-agent/src/llm.rs
@@ -0,0 +1,388 @@
+use reqwest::Client;
+use serde_json::{json, Value};
+
+use crate::config::{Config, Provider};
+use crate::types::{AgentError, HistoryItem, LlmResponse, ProviderStop, ToolCall, ToolDef};
+
+const MAX_LLM_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
+const MAX_LLM_ERROR_BODY_BYTES: usize = 4 * 1024;
+
+pub struct Llm {
+    http: Client,
+}
+
+impl Llm {
+    pub fn new(cfg: &Config) -> Result<Self, AgentError> {
+        let http = Client::builder()
+            .connect_timeout(std::time::Duration::from_secs(10))
+            .timeout(cfg.llm_timeout)
+            .build()
+            .map_err(|e| AgentError::Llm(format!("http: {e}")))?;
+        Ok(Self { http })
+    }
+
+    pub async fn complete(
+        &self,
+        cfg: &Config,
+        history: &[HistoryItem],
+        tools: &[ToolDef],
+    ) -> Result<LlmResponse, AgentError> {
+        match cfg.provider {
+            Provider::Anthropic => {
+                let body = anthropic_body(cfg, history, tools);
+                let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/'));
+                let v = post(&self.http, &url, &body, |r| {
+                    r.header("x-api-key", &cfg.api_key)
+                        .header("anthropic-version", &cfg.anthropic_api_version)
+                        .header("content-type", "application/json")
+                })
+                .await?;
+                parse_anthropic(v)
+            }
+            Provider::OpenAi => {
+                let body = openai_body(cfg, history, tools);
+                let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/'));
+                let v = post(&self.http, &url, &body, |r| {
+                    r.bearer_auth(&cfg.api_key)
+                        .header("content-type", "application/json")
+                })
+                .await?;
+                parse_openai(v)
+            }
+        }
+    }
+
+    pub async fn summarize(
+        &self,
+        cfg: &Config,
+        system_prompt: &str,
+        user_prompt: &str,
+        max_output_tokens: u32,
+    ) -> Result<String, AgentError> {
+        match cfg.provider {
+            Provider::Anthropic => {
+                let body = json!({
+                    "model": cfg.model,
+                    "max_tokens": max_output_tokens,
+                    "system": system_prompt,
+                    "messages": [{
+                        "role": "user",
+                        "content": [{ "type": "text", "text": user_prompt }],
+                    }],
+                });
+                let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/'));
+                let v = post(&self.http, &url, &body, |r| {
+                    r.header("x-api-key", &cfg.api_key)
+                        .header("anthropic-version", &cfg.anthropic_api_version)
+                        .header("content-type", "application/json")
+                })
+                .await?;
+                Ok(parse_anthropic(v)?.text)
+            }
+            Provider::OpenAi => {
+                let body = json!({
+                    "model": cfg.model,
+                    "stream": false,
+                    "max_tokens": max_output_tokens,
+                    "messages": [
+                        { "role": "system", "content": system_prompt },
+                        { "role": "user", "content": user_prompt },
+                    ],
+                });
+                let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/'));
+                let v = post(&self.http, &url, &body, |r| {
+                    r.bearer_auth(&cfg.api_key)
+                        .header("content-type", "application/json")
+                })
+                .await?;
+                Ok(parse_openai(v)?.text)
+            }
+        }
+    }
+}
+
+fn anthropic_body(cfg: &Config, history: &[HistoryItem], tools: &[ToolDef]) -> Value {
+    let mut messages: Vec<Value> = Vec::new();
+    let mut pending: Vec<Value> = Vec::new();
+    let flush = |out: &mut Vec<Value>, p: &mut Vec<Value>| {
+        if !p.is_empty() {
+            out.push(json!({ "role": "user", "content": std::mem::take(p) }));
+        }
+    };
+    for item in history {
+        match item {
+            HistoryItem::User(text) => {
+                flush(&mut messages, &mut pending);
+                messages.push(json!({ "role": "user",
+                    "content": [{ "type": "text", "text": text }] }));
+            }
+            HistoryItem::Assistant { text, tool_calls } => {
+                flush(&mut messages, &mut pending);
+                let mut content: Vec<Value> = Vec::new();
+                if !text.is_empty() {
+                    content.push(json!({ "type": "text", "text": text }));
+                }
+                for c in tool_calls {
+                    content.push(json!({ "type": "tool_use", "id": c.provider_id,
+                        "name": c.name, "input": c.arguments }));
+                }
+                if content.is_empty() {
+                    content.push(json!({ "type": "text", "text": "" }));
+                }
+                messages.push(json!({ "role": "assistant", "content": content }));
+            }
+            HistoryItem::ToolResult(r) => pending.push(json!({
+                "type": "tool_result", "tool_use_id": r.provider_id,
+                "content": [{ "type": "text", "text": r.text }], "is_error": r.is_error })),
+        }
+    }
+    flush(&mut messages, &mut pending);
+    let tools_json: Vec<Value> = tools
+        .iter()
+        .map(|t| {
+            json!({
+        "name": t.name, "description": t.description, "input_schema": t.input_schema })
+        })
+        .collect();
+    let mut body = json!({ "model": cfg.model, "max_tokens": cfg.max_output_tokens,
+        "system": cfg.system_prompt, "messages": messages });
+    if !tools_json.is_empty() {
+        body["tools"] = Value::Array(tools_json);
+    }
+    body
+}
+
+fn openai_body(cfg: &Config, history: &[HistoryItem], tools: &[ToolDef]) -> Value {
+    let mut messages: Vec<Value> = vec![json!({ "role": "system", "content": cfg.system_prompt })];
+    for item in history {
+        match item {
+            HistoryItem::User(text) => messages.push(json!({ "role": "user", "content": text })),
+            HistoryItem::Assistant { text, tool_calls } => {
+                let mut msg = serde_json::Map::new();
+                msg.insert("role".into(), json!("assistant"));
+                msg.insert("content".into(), json!(text.as_str()));
+                if !tool_calls.is_empty() {
+                    let calls: Vec<Value> = tool_calls
+                        .iter()
+                        .map(|c| {
+                            json!({
+                        "id": c.provider_id, "type": "function",
+                        "function": { "name": c.name,
+                            "arguments": serde_json::to_string(&c.arguments)
+                                .unwrap_or_else(|_| "{}".into()) } })
+                        })
+                        .collect();
+                    msg.insert("tool_calls".into(), Value::Array(calls));
+                }
+                messages.push(Value::Object(msg));
+            }
+            HistoryItem::ToolResult(r) => messages.push(json!({
+                "role": "tool", "tool_call_id": r.provider_id, "content": r.text })),
+        }
+    }
+    let tools_json: Vec<Value> = tools
+        .iter()
+        .map(|t| {
+            json!({
+        "type": "function",
+        "function": { "name": t.name, "description": t.description,
+            "parameters": t.input_schema } })
+        })
+        .collect();
+    let mut body = json!({ "model": cfg.model, "stream": false, "max_tokens": cfg.max_output_tokens,
+        "messages": messages });
+    if !tools_json.is_empty() {
+        body["tools"] = Value::Array(tools_json);
+        body["tool_choice"] = json!("auto");
+    }
+    body
+}
+
+fn map_stop(s: Option<&str>) -> ProviderStop {
+    match s {
+        Some("end_turn" | "stop") => ProviderStop::EndTurn,
+        Some("tool_use" | "tool_calls") => ProviderStop::ToolUse,
+        Some("max_tokens" | "length") => ProviderStop::MaxTokens,
+        Some("refusal" | "content_filter") => ProviderStop::Refusal,
+        _ => ProviderStop::Other,
+    }
+}
+
+fn str_field(v: &Value, key: &str) -> String {
+    v.get(key).and_then(Value::as_str).unwrap_or("").to_owned()
+}
+
+fn parse_anthropic(v: Value) -> Result<LlmResponse, AgentError> {
+    let stop = map_stop(v.get("stop_reason").and_then(Value::as_str));
+    let mut tool_calls = Vec::new();
+    let mut text = String::new();
+    if let Some(blocks) = v.get("content").and_then(Value::as_array) {
+        for b in blocks {
+            match b.get("type").and_then(Value::as_str) {
+                Some("text") => {
+                    if let Some(t) = b.get("text").and_then(Value::as_str) {
+                        text.push_str(t);
+                    }
+                }
+                Some("tool_use") => tool_calls.push(make_tool_call(
+                    str_field(b, "id"),
+                    str_field(b, "name"),
+                    b.get("input").cloned().unwrap_or(Value::Null),
+                )?),
+                _ => {}
+            }
+        }
+    }
+    Ok(LlmResponse {
+        text,
+        tool_calls,
+        stop,
+    })
+}
+
+fn parse_openai(v: Value) -> Result<LlmResponse, AgentError> {
+    let choice = v
+        .get("choices")
+        .and_then(Value::as_array)
+        .and_then(|a| a.first())
+        .ok_or_else(|| AgentError::Llm("response missing choices".into()))?;
+    let stop = map_stop(choice.get("finish_reason").and_then(Value::as_str));
+    let msg = choice
+        .get("message")
+        .ok_or_else(|| AgentError::Llm("missing message".into()))?;
+    let text = str_field(msg, "content");
+    let mut tool_calls = Vec::new();
+    if let Some(arr) = msg.get("tool_calls").and_then(Value::as_array) {
+        for tc in arr {
+            let f = tc
+                .get("function")
+                .ok_or_else(|| AgentError::Llm("tool_call missing function".into()))?;
+            let raw = f.get("arguments").and_then(Value::as_str).unwrap_or("{}");
+            let args: Value = serde_json::from_str(raw)
+                .map_err(|e| AgentError::Llm(format!("tool_call.arguments not valid JSON: {e}")))?;
+            tool_calls.push(make_tool_call(
+                str_field(tc, "id"),
+                str_field(f, "name"),
+                args,
+            )?);
+        }
+    }
+    Ok(LlmResponse {
+        text,
+        tool_calls,
+        stop,
+    })
+}
+
+fn make_tool_call(id: String, name: String, args: Value) -> Result<ToolCall, AgentError> {
+    if id.is_empty() || name.is_empty() {
+        return Err(AgentError::Llm("tool_call missing id or name".into()));
+    }
+    let arguments = match args {
+        Value::Object(_) => args,
+        Value::Null => Value::Object(Default::default()),
+        _ => {
+            return Err(AgentError::Llm(
+                "tool_call arguments must be a JSON object".into(),
+            ))
+        }
+    };
+    Ok(ToolCall {
+        provider_id: id,
+        name,
+        arguments,
+    })
+}
+
+async fn read_error_body(mut resp: reqwest::Response) -> String {
+    let mut buf: Vec<u8> = Vec::new();
+    while buf.len() < MAX_LLM_ERROR_BODY_BYTES {
+        match resp.chunk().await {
+            Ok(Some(chunk)) => {
+                let take = chunk.len().min(MAX_LLM_ERROR_BODY_BYTES - buf.len());
+                buf.extend_from_slice(&chunk[..take]);
+                if take < chunk.len() {
+                    break;
+                }
+            }
+            _ => break,
+        }
+    }
+    String::from_utf8_lossy(&buf).into_owned()
+}
+
+const MAX_RETRIES: u32 = 3;
+const BASE_BACKOFF_MS: u64 = 500;
+const MAX_BACKOFF_MS: u64 = 8_000;
+
+async fn backoff_with_jitter(attempt: u32) {
+    let base = BASE_BACKOFF_MS
+        .saturating_mul(1u64 << attempt)
+        .min(MAX_BACKOFF_MS);
+    let mut buf = [0u8; 8];
+    let jitter_range = base / 2;
+    let delay = if jitter_range > 0 && getrandom::getrandom(&mut buf).is_ok() {
+        let r = u64::from_le_bytes(buf) % jitter_range;
+        base - jitter_range + r
+    } else {
+        base
+    };
+    tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
+}
+
+async fn post<F>(http: &Client, url: &str, body: &Value, apply: F) -> Result<Value, AgentError>
+where
+    F: Fn(reqwest::RequestBuilder) -> reqwest::RequestBuilder,
+{
+    for attempt in 0..MAX_RETRIES {
+        let resp = match apply(http.post(url).json(body)).send().await {
+            Ok(r) => r,
+            Err(e) => {
+                if attempt + 1 < MAX_RETRIES && (e.is_timeout() || e.is_connect()) {
+                    backoff_with_jitter(attempt).await;
+                    continue;
+                }
+                return Err(AgentError::Llm(format!("transport: {e}")));
+            }
+        };
+        let status = resp.status();
+        if status == 401 || status == 403 {
+            return Err(AgentError::LlmAuth(read_error_body(resp).await));
+        }
+        if (status.is_server_error() || status == 429) && attempt + 1 < MAX_RETRIES {
+            backoff_with_jitter(attempt).await;
+            continue;
+        }
+        if !status.is_success() {
+            return Err(AgentError::Llm(format!(
+                "{status}: {}",
+                read_error_body(resp).await
+            )));
+        }
+        if let Some(len) = resp.content_length() {
+            if len as usize > MAX_LLM_RESPONSE_BYTES {
+                return Err(AgentError::Llm(format!(
+                    "response too large: {len} > {MAX_LLM_RESPONSE_BYTES}"
+                )));
+            }
+        }
+        let mut buf: Vec<u8> = Vec::new();
+        let mut stream = resp;
+        loop {
+            match stream.chunk().await {
+                Ok(Some(chunk)) => {
+                    if buf.len() + chunk.len() > MAX_LLM_RESPONSE_BYTES {
+                        return Err(AgentError::Llm(format!(
+                            "response exceeded {MAX_LLM_RESPONSE_BYTES} bytes"
+                        )));
+                    }
+                    buf.extend_from_slice(&chunk);
+                }
+                Ok(None) => break,
+                Err(e) => return Err(AgentError::Llm(format!("read: {e}"))),
+            }
+        }
+        return serde_json::from_slice(&buf).map_err(|e| AgentError::Llm(format!("json: {e}")));
+    }
+    Err(AgentError::Llm("exhausted retries".into()))
+}
diff --git a/crates/sprout-agent/src/main.rs b/crates/sprout-agent/src/main.rs
new file mode 100644
index 0000000000..bc56677f99
--- /dev/null
+++ b/crates/sprout-agent/src/main.rs
@@ -0,0 +1,361 @@
+#![forbid(unsafe_code)]
+mod agent;
+mod config;
+mod handoff;
+mod llm;
+mod mcp;
+mod types;
+mod wire;
+
+use std::collections::HashMap;
+use std::path::Path;
+use std::sync::Arc;
+
+use serde_json::{json, Value};
+use tokio::io::BufReader;
+use tokio::sync::{mpsc, watch, Mutex};
+
+use crate::agent::RunCtx;
+use crate::config::{Config, PROTOCOL_VERSION};
+use crate::llm::Llm;
+use crate::mcp::McpRegistry;
+use crate::types::HistoryItem;
+use crate::wire::{
+    classify, Inbound, InitializeParams, SessionCancelParams, SessionNewParams,
+    SessionPromptParams, WireMsg, WireSender, INVALID_PARAMS, METHOD_NOT_FOUND, PARSE_ERROR,
+};
+
+struct App {
+    cfg: Config,
+    llm: Arc<Llm>,
+    sessions: Mutex<HashMap<String, Session>>,
+}
+
+struct Session {
+    id: String,
+    mcp: Arc<McpRegistry>,
+    history: Vec<HistoryItem>,
+    cancel_tx: watch::Sender<bool>,
+    busy: bool,
+    original_task: Option<String>,
+    handoff_count: usize,
+    stop_rejections: u32,
+}
+
+fn die(msg: String) -> ! {
+    tracing::error!("{msg}");
+    std::process::exit(2);
+}
+
+#[tokio::main(flavor = "multi_thread")]
+async fn main() {
+    tracing_subscriber::fmt()
+        .with_writer(std::io::stderr)
+        .with_ansi(false)
+        .init();
+    let cfg = Config::from_env().unwrap_or_else(|e| die(e));
+    let llm = Arc::new(Llm::new(&cfg).unwrap_or_else(|e| die(e.to_string())));
+    let max_line = cfg.max_line_bytes;
+    let app = Arc::new(App {
+        cfg,
+        llm,
+        sessions: Mutex::new(HashMap::new()),
+    });
+    let (wire_tx, wire_rx) = mpsc::channel::<WireMsg>(64);
+    let writer = tokio::spawn(wire::writer_task(wire_rx));
+    if let Err(e) = read_loop(
+        BufReader::new(tokio::io::stdin()),
+        app.clone(),
+        wire_tx,
+        max_line,
+    )
+    .await
+    {
+        tracing::error!("io: reader: {e}");
+    }
+    for session in app.sessions.lock().await.values() {
+        let _ = session.cancel_tx.send(true);
+    }
+    let _ = writer.await;
+}
+
+async fn read_loop<R: tokio::io::AsyncBufRead + Unpin>(
+    mut stdin: R,
+    app: Arc<App>,
+    wire_tx: WireSender,
+    max_line: usize,
+) -> std::io::Result<()> {
+    while let Some(line) = wire::read_bounded_line(&mut stdin, max_line).await? {
+        if line.trim().is_empty() {
+            continue;
+        }
+        match serde_json::from_str::<Value>(&line) {
+            Ok(msg) => dispatch(&app, msg, &wire_tx).await,
+            Err(e) => {
+                wire::send(
+                    &wire_tx,
+                    wire::err(Value::Null, PARSE_ERROR, &format!("jsonrpc: parse: {e}")),
+                )
+                .await;
+            }
+        }
+    }
+    Ok(())
+}
+
+async fn dispatch(app: &Arc<App>, msg: Value, wire_tx: &WireSender) {
+    match classify(&msg) {
+        Inbound::Request { id, method, params } => {
+            handle_request(app, id, method, params, wire_tx).await
+        }
+        Inbound::Notification { method, params } => handle_notification(app, &method, params).await,
+        Inbound::Ignored => {}
+        Inbound::Invalid { id, code, message } => {
+            wire::send(wire_tx, wire::err(id, code, &message)).await
+        }
+    }
+}
+
+async fn handle_request(
+    app: &Arc<App>,
+    id: Value,
+    method: String,
+    params: Value,
+    wire_tx: &WireSender,
+) {
+    match method.as_str() {
+        "initialize" => initialize(id, params, wire_tx).await,
+        "session/new" => {
+            let app = app.clone();
+            let wire_tx = wire_tx.clone();
+            tokio::spawn(async move { session_new(&app, id, params, &wire_tx).await });
+        }
+        "session/prompt" => spawn_prompt(app.clone(), id, params, wire_tx.clone()),
+        "session/cancel" => {
+            cancel_session(app, params).await;
+            wire::send(wire_tx, wire::ok(id, Value::Null)).await;
+        }
+        _ => {
+            wire::send(
+                wire_tx,
+                wire::err(
+                    id,
+                    METHOD_NOT_FOUND,
+                    &format!("jsonrpc: method not found: {method}"),
+                ),
+            )
+            .await
+        }
+    }
+}
+
+async fn handle_notification(app: &Arc<App>, method: &str, params: Value) {
+    if method == "session/cancel" {
+        cancel_session(app, params).await;
+    }
+}
+
+async fn initialize(id: Value, params: Value, wire_tx: &WireSender) {
+    let p: InitializeParams = match decode(params, "initialize") {
+        Ok(p) => p,
+        Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await,
+    };
+    let _ = p.protocol_version;
+    wire::send(
+        wire_tx,
+        wire::ok(
+            id,
+            json!({
+                "protocolVersion": PROTOCOL_VERSION,
+                "agentCapabilities": {
+                    "loadSession": false,
+                    "promptCapabilities": { "image": false, "audio": false, "embeddedContext": false },
+                    "mcpCapabilities": { "http": false, "sse": false },
+                },
+                "agentInfo": { "name": "sprout-agent", "version": env!("CARGO_PKG_VERSION") },
+            }),
+        ),
+    )
+    .await;
+}
+
+async fn session_new(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSender) {
+    let p: SessionNewParams = match decode(params, "session/new") {
+        Ok(p) => p,
+        Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await,
+    };
+    if p.cwd.is_empty() || !Path::new(&p.cwd).is_absolute() {
+        return reject(
+            wire_tx,
+            id,
+            INVALID_PARAMS,
+            "session/new: cwd must be an absolute path",
+        )
+        .await;
+    }
+    // Check cap without holding lock across MCP spawn (which may be slow).
+    {
+        let sessions = app.sessions.lock().await;
+        if sessions.len() >= app.cfg.max_sessions {
+            return reject(
+                wire_tx,
+                id,
+                INVALID_PARAMS,
+                "session/new: max sessions reached",
+            )
+            .await;
+        }
+    }
+    let mcp = match McpRegistry::spawn_all(&app.cfg, &p.mcp_servers, &p.cwd).await {
+        Ok(m) => Arc::new(m),
+        Err(e) => return reject(wire_tx, id, e.json_rpc_code(), &e.to_string()).await,
+    };
+    let session_id = match session_token() {
+        Ok(t) => format!("ses_{t}"),
+        Err(e) => return reject(wire_tx, id, -32000, &e).await,
+    };
+    let (cancel_tx, _) = watch::channel(false);
+    let mut sessions = app.sessions.lock().await;
+    // Re-check cap (another session may have been created while we spawned MCP).
+    if sessions.len() >= app.cfg.max_sessions {
+        return reject(
+            wire_tx,
+            id,
+            INVALID_PARAMS,
+            "session/new: max sessions reached",
+        )
+        .await;
+    }
+    sessions.insert(
+        session_id.clone(),
+        Session {
+            id: session_id.clone(),
+            mcp,
+            history: Vec::new(),
+            cancel_tx,
+            busy: false,
+            original_task: None,
+            handoff_count: 0,
+            stop_rejections: 0,
+        },
+    );
+    drop(sessions);
+    wire::send(wire_tx, wire::ok(id, json!({ "sessionId": session_id }))).await;
+}
+
+fn decode<T: serde::de::DeserializeOwned>(params: Value, stage: &str) -> Result<T, String> {
+    serde_json::from_value(params).map_err(|e| format!("{stage}: {e}"))
+}
+
+async fn reject(wire_tx: &WireSender, id: Value, code: i32, message: &str) {
+    wire::send(wire_tx, wire::err(id, code, message)).await;
+}
+
+async fn cancel_session(app: &Arc<App>, params: Value) {
+    if let Ok(p) = serde_json::from_value::<SessionCancelParams>(params) {
+        if let Some(s) = app.sessions.lock().await.get(&p.session_id) {
+            let _ = s.cancel_tx.send(true);
+        }
+    }
+}
+
+fn spawn_prompt(app: Arc<App>, id: Value, params: Value, wire_tx: WireSender) {
+    tokio::spawn(async move { run_prompt(app, id, params, wire_tx).await });
+}
+
+async fn run_prompt(app: Arc<App>, id: Value, params: Value, wire_tx: WireSender) {
+    let p: SessionPromptParams = match decode(params, "session/prompt") {
+        Ok(p) => p,
+        Err(m) => return reject(&wire_tx, id, INVALID_PARAMS, &m).await,
+    };
+    let (
+        sid,
+        mcp,
+        mut history,
+        mut original_task,
+        mut handoff_count,
+        mut stop_rejections,
+        mut cancel_rx,
+    ) = match acquire_session(&app, &p.session_id).await {
+        Ok(v) => v,
+        Err(reason) => {
+            return reject(
+                &wire_tx,
+                id,
+                INVALID_PARAMS,
+                &format!("session/prompt: {reason}"),
+            )
+            .await
+        }
+    };
+    let mut ctx = RunCtx {
+        cfg: &app.cfg,
+        session_id: &sid,
+        llm: &app.llm,
+        mcp: &mcp,
+        wire: &wire_tx,
+        cancel: &mut cancel_rx,
+        history: &mut history,
+        original_task: &mut original_task,
+        handoff_count: &mut handoff_count,
+        stop_rejections: &mut stop_rejections,
+    };
+    let result = ctx.run(p.prompt).await;
+    if let Some(s) = app.sessions.lock().await.get_mut(&sid) {
+        s.busy = false;
+        s.history = history;
+        s.original_task = original_task;
+        s.handoff_count = handoff_count;
+        s.stop_rejections = stop_rejections;
+    }
+    match result {
+        Ok(stop) => {
+            wire::send(
+                &wire_tx,
+                wire::ok(id, json!({ "stopReason": stop.as_wire() })),
+            )
+            .await
+        }
+        Err(e) => wire::send(&wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await,
+    }
+}
+
+async fn acquire_session(
+    app: &Arc<App>,
+    session_id: &str,
+) -> Result<
+    (
+        String,
+        Arc<McpRegistry>,
+        Vec<HistoryItem>,
+        Option<String>,
+        usize,
+        u32,
+        watch::Receiver<bool>,
+    ),
+    &'static str,
+> {
+    let mut sessions = app.sessions.lock().await;
+    let s = sessions.get_mut(session_id).ok_or("unknown session")?;
+    if s.busy {
+        return Err("prompt already in flight");
+    }
+    s.busy = true;
+    let (tx, rx) = watch::channel(false);
+    s.cancel_tx = tx;
+    Ok((
+        s.id.clone(),
+        s.mcp.clone(),
+        std::mem::take(&mut s.history),
+        s.original_task.take(),
+        s.handoff_count,
+        s.stop_rejections,
+        rx,
+    ))
+}
+
+fn session_token() -> Result<String, String> {
+    let mut b = [0u8; 8];
+    getrandom::getrandom(&mut b).map_err(|e| format!("rng: getrandom failed: {e}"))?;
+    Ok(b.iter().map(|x| format!("{x:02x}")).collect())
+}
diff --git a/crates/sprout-agent/src/mcp.rs b/crates/sprout-agent/src/mcp.rs
new file mode 100644
index 0000000000..4e6379d733
--- /dev/null
+++ b/crates/sprout-agent/src/mcp.rs
@@ -0,0 +1,860 @@
+use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use arc_swap::ArcSwap;
+use rmcp::model::CallToolRequestParams;
+use rmcp::service::{RoleClient, RunningService};
+use rmcp::transport::TokioChildProcess;
+use rmcp::ServiceError;
+use rmcp::ServiceExt;
+use serde_json::{Map, Value};
+use tokio::process::Command;
+use tokio::sync::watch;
+use tokio::sync::Mutex as AsyncMutex;
+
+use crate::config::{Config, HookServers};
+use crate::types::{clamp, AgentError, McpServerStdio, ToolDef, ToolResult};
+
+const SEP: &str = "__";
+const MAX_NAME_LEN: usize = 128;
+const MAX_QNAME_LEN: usize = 64;
+const MAX_TOOLS_PER_SESSION: usize = 128;
+const MAX_DESCRIPTION_BYTES: usize = 1024;
+const MAX_SCHEMA_BYTES: usize = 4096;
+const MARKER_FIELD_MAX: usize = 256;
+pub const MAX_MCP_SERVERS: usize = 16;
+const MAX_HOOK_RESULT_BYTES: usize = 16 * 1024;
+
+const PASSTHROUGH_ENV: &[&str] = &["PATH", "HOME", "TERM", "LANG", "LC_ALL", "TMPDIR"];
+
+type Client = RunningService<RoleClient, ()>;
+
+#[derive(Clone)]
+struct ServerSpec {
+    name: String,
+    command: String,
+    args: Vec<String>,
+    env: Vec<(String, String)>,
+    cwd: String,
+}
+
+enum ClientState {
+    Healthy {
+        client: Arc<Client>,
+        pgid: Option<u32>,
+        tools: Arc<Vec<String>>,
+    },
+    Dead {
+        attempts: u32,
+        next_retry: Instant,
+        reason: String,
+        // Preserved from the last Healthy state so tools() filtering stays accurate while dead.
+        tools: Arc<Vec<String>>,
+    },
+}
+
+struct Server {
+    name: String,
+    spec: ServerSpec,
+    client: ArcSwap<ClientState>,
+    restart_lock: AsyncMutex<()>,
+}
+
+impl Drop for Server {
+    fn drop(&mut self) {
+        if let ClientState::Healthy { pgid: Some(p), .. } = &**self.client.load() {
+            killpg(*p, &self.name, "drop");
+        }
+    }
+}
+
+enum RestartCheck {
+    Healthy,
+    Ready {
+        attempt_n: u32,
+        prev_tools: Arc<Vec<String>>,
+    },
+}
+
+fn check_restart_state(server: &Server, max_attempts: u32) -> Result<RestartCheck, AgentError> {
+    match &**server.client.load() {
+        ClientState::Healthy { .. } => Ok(RestartCheck::Healthy),
+        ClientState::Dead { attempts, .. } if *attempts >= max_attempts => {
+            Err(AgentError::Mcp(format!(
+                "The MCP server '{}' is unavailable (exhausted). Its tools have been removed for this session.",
+                server.name
+            )))
+        }
+        ClientState::Dead { next_retry, reason, .. } if Instant::now() < *next_retry => {
+            Err(AgentError::Mcp(format!(
+                "server '{}' is recovering (last error: {reason}). Try again later or use a different tool.",
+                server.name
+            )))
+        }
+        ClientState::Dead { attempts, tools, .. } => Ok(RestartCheck::Ready {
+            attempt_n: attempts + 1,
+            prev_tools: tools.clone(),
+        }),
+    }
+}
+
+struct Entry {
+    server_idx: usize,
+    bare: String,
+}
+
+pub struct McpRegistry {
+    by_qname: HashMap<String, Entry>,
+    defs: Vec<ToolDef>,
+    servers: Vec<Arc<Server>>,
+    max_attempts: u32,
+    backoff_base: Duration,
+    backoff_max: Duration,
+    init_timeout: Duration,
+    /// Consecutive hook timeout count per server. Kill on second consecutive.
+    hook_timeouts: std::sync::Mutex<HashMap<String, u32>>,
+}
+
+impl McpRegistry {
+    pub async fn spawn_all(
+        cfg: &Config,
+        servers: &[McpServerStdio],
+        cwd: &str,
+    ) -> Result<Self, AgentError> {
+        if servers.len() > MAX_MCP_SERVERS {
+            return Err(AgentError::Mcp(format!(
+                "too many MCP servers: {} > {MAX_MCP_SERVERS}",
+                servers.len()
+            )));
+        }
+        let mut reg = Self {
+            by_qname: HashMap::new(),
+            defs: Vec::new(),
+            servers: Vec::new(),
+
+            max_attempts: cfg.mcp_max_restart_attempts.max(1),
+            backoff_base: Duration::from_millis(cfg.mcp_restart_base_ms.max(1)),
+            backoff_max: Duration::from_millis(cfg.mcp_restart_max_ms.max(1)),
+            init_timeout: cfg.mcp_init_timeout,
+            hook_timeouts: std::sync::Mutex::new(HashMap::new()),
+        };
+
+        let mut seen_names = HashSet::new();
+        for s in servers {
+            if !valid_name(&s.name) || s.name.contains("__") {
+                return Err(AgentError::Mcp(format!("invalid server name: {}", s.name)));
+            }
+            if !seen_names.insert(s.name.clone()) {
+                return Err(AgentError::Mcp(format!(
+                    "duplicate server name: {}",
+                    s.name
+                )));
+            }
+            let spec = ServerSpec {
+                name: s.name.clone(),
+                command: s.command.clone(),
+                args: s.args.clone(),
+                env: s
+                    .env
+                    .iter()
+                    .map(|e| (e.name.clone(), e.value.clone()))
+                    .collect(),
+                cwd: cwd.to_owned(),
+            };
+            let (client, pgid, tool_names, raw_tools) = spawn_one(&spec, reg.init_timeout).await?;
+            let server_idx = reg.servers.len();
+            let server = Arc::new(Server {
+                name: spec.name.clone(),
+                spec,
+                client: ArcSwap::from_pointee(ClientState::Healthy {
+                    client: Arc::new(client),
+                    pgid,
+                    tools: Arc::new(tool_names),
+                }),
+                restart_lock: AsyncMutex::new(()),
+            });
+            reg.servers.push(server);
+
+            for t in raw_tools {
+                if reg.defs.len() >= MAX_TOOLS_PER_SESSION {
+                    return Err(AgentError::Mcp(format!(
+                        "too many tools (>{MAX_TOOLS_PER_SESSION})"
+                    )));
+                }
+                let bare = t.name.to_string();
+                if !valid_name(&bare) || bare.contains("__") {
+                    return Err(AgentError::Mcp(format!("invalid tool name: {bare}")));
+                }
+                let qname = format!("{}{SEP}{}", s.name, bare);
+                if qname.len() > MAX_QNAME_LEN {
+                    return Err(AgentError::Mcp(format!(
+                        "qualified tool name too long: {} ({} > {MAX_QNAME_LEN})",
+                        qname,
+                        qname.len()
+                    )));
+                }
+                if reg.by_qname.contains_key(&qname) {
+                    return Err(AgentError::Mcp(format!("duplicate tool: {qname}")));
+                }
+                reg.defs.push(ToolDef {
+                    name: qname.clone(),
+                    description: clamp(
+                        t.description.as_deref().unwrap_or("").to_owned(),
+                        MAX_DESCRIPTION_BYTES,
+                    ),
+                    input_schema: cap_schema(&qname, Value::Object((*t.input_schema).clone())),
+                });
+                reg.by_qname.insert(qname, Entry { server_idx, bare });
+            }
+        }
+        Ok(reg)
+    }
+
+    pub fn server_of(&self, qname: &str) -> Option<&str> {
+        self.by_qname
+            .get(qname)
+            .map(|e| self.servers[e.server_idx].name.as_str())
+    }
+
+    pub fn has(&self, qname: &str) -> bool {
+        self.by_qname.contains_key(qname)
+    }
+
+    /// True if `qname` resolves to a hidden hook tool (bare name starts
+    /// with `_`). Used to reject hook calls coming from the LLM path —
+    /// hooks are only callable via `call_hooks`.
+    pub fn is_hook(&self, qname: &str) -> bool {
+        self.by_qname
+            .get(qname)
+            .map(|e| e.bare.starts_with('_'))
+            .unwrap_or(false)
+    }
+
+    pub fn tools(&self) -> Vec<ToolDef> {
+        self.defs
+            .iter()
+            .filter(|d| {
+                let entry = match self.by_qname.get(&d.name) {
+                    Some(e) => e,
+                    None => return false,
+                };
+                // Bare names starting with `_` are hooks — invisible to the LLM.
+                if entry.bare.starts_with('_') {
+                    return false;
+                }
+                let server = &self.servers[entry.server_idx];
+                match &**server.client.load() {
+                    ClientState::Healthy { tools, .. } => tools.iter().any(|t| t == &entry.bare),
+                    ClientState::Dead {
+                        attempts, tools, ..
+                    } => *attempts < self.max_attempts && tools.iter().any(|t| t == &entry.bare),
+                }
+            })
+            .cloned()
+            .collect()
+    }
+
+    /// Call every tool whose bare name equals `hook_name` across all
+    /// allowlisted servers in parallel, bounded by `timeout`. Returns
+    /// `(server_name, text)` pairs in **config order** (deterministic),
+    /// dropping empty/whitespace-only responses, errors and timeouts.
+    /// Hooks are fail-open and must never block the agent.
+    pub async fn call_hooks(
+        self: &Arc<Self>,
+        hook_name: &str,
+        input: &Value,
+        timeout: Duration,
+        allowed: &HookServers,
+    ) -> Vec<(String, String)> {
+        if allowed.is_disabled() {
+            return Vec::new();
+        }
+        // Walk servers in registration order so the result is deterministic
+        // regardless of HashMap iteration order or task completion order.
+        let mut targets: Vec<(usize, String, String)> = Vec::new();
+        for (idx, server) in self.servers.iter().enumerate() {
+            if !allowed.allows(&server.name) {
+                continue;
+            }
+            let qname = format!("{}{SEP}{}", server.name, hook_name);
+            if self.by_qname.contains_key(&qname) {
+                targets.push((idx, server.name.clone(), qname));
+            }
+        }
+        if targets.is_empty() {
+            return Vec::new();
+        }
+        let mut set = tokio::task::JoinSet::new();
+        for (idx, server_name, qname) in targets {
+            let reg = Arc::clone(self);
+            let args = input.clone();
+            set.spawn(async move {
+                // Hooks are intentionally non-cancellable: they are
+                // already bounded by their own timeout and are fail-open.
+                // Session cancel should not interrupt hook evaluation.
+                let (_dummy_tx, mut dummy_cancel) = watch::channel(false);
+                let res = tokio::time::timeout(
+                    timeout,
+                    reg.call(
+                        &qname,
+                        "hook",
+                        &args,
+                        MAX_HOOK_RESULT_BYTES,
+                        &mut dummy_cancel,
+                    ),
+                )
+                .await;
+                drop(_dummy_tx);
+                (idx, server_name, res)
+            });
+        }
+        let mut indexed: Vec<(usize, String, String)> = Vec::new();
+        while let Some(joined) = set.join_next().await {
+            // fail-open: drop join errors, timeouts, call errors,
+            // empty/whitespace-only text. On timeout, also kill the server
+            // process group so a wedged hook can't poison the next regular
+            // tool call. The registry's lazy restart handles the rest.
+            match joined {
+                Ok((idx, server_name, Ok(Ok(r)))) => {
+                    // Success — reset consecutive timeout counter.
+                    if let Ok(mut counts) = self.hook_timeouts.lock() {
+                        counts.remove(&server_name);
+                    }
+                    if !r.is_error && !r.text.trim().is_empty() {
+                        indexed.push((idx, server_name, r.text));
+                    }
+                }
+                Ok((_idx, server_name, Err(_elapsed))) => {
+                    // Kill only on second consecutive timeout.
+                    let count = {
+                        let mut counts =
+                            self.hook_timeouts.lock().unwrap_or_else(|e| e.into_inner());
+                        let c = counts.entry(server_name.clone()).or_insert(0);
+                        *c += 1;
+                        *c
+                    };
+                    if count >= 2 {
+                        tracing::warn!(
+                            "hook: killing server '{}' after {} consecutive timeouts",
+                            server_name,
+                            count
+                        );
+                        self.kill_server(&server_name, "hook timeout (consecutive)");
+                        if let Ok(mut counts) = self.hook_timeouts.lock() {
+                            counts.remove(&server_name);
+                        }
+                    } else {
+                        tracing::warn!("hook: server '{}' timed out ({}/2)", server_name, count);
+                    }
+                }
+                _ => {}
+            }
+        }
+        indexed.sort_by_key(|(idx, _, _)| *idx);
+        indexed
+            .into_iter()
+            .map(|(_, name, text)| (name, text))
+            .collect()
+    }
+
+    /// Kill the server's process group and mark it dead. Idempotent:
+    /// if the server is already Dead (or unknown), this is a no-op.
+    /// Counts as one attempt toward the restart budget so that a
+    /// pathological server (starts fine, deadlocks on every call)
+    /// eventually exhausts.
+    pub fn kill_server(&self, name: &str, reason: &str) {
+        let server = match self.servers.iter().find(|s| s.name == name) {
+            Some(s) => s,
+            None => return,
+        };
+        let current = server.client.load_full();
+        let (pgid, tools) = match &*current {
+            ClientState::Dead { .. } => return,
+            ClientState::Healthy { pgid, tools, .. } => (*pgid, tools.clone()),
+        };
+        let dead = Arc::new(ClientState::Dead {
+            attempts: 1,
+            next_retry: Instant::now() + backoff(1, self.backoff_base, self.backoff_max),
+            reason: reason.to_owned(),
+            tools,
+        });
+        // CAS so we don't clobber a concurrent restart that already
+        // transitioned the state. If the swap fails, the kill below is
+        // still safe — the pgid we read belonged to a process we observed
+        // as Healthy, and killpg on an already-reaped pgid is a no-op.
+        let prev = server.client.compare_and_swap(&current, dead);
+        if Arc::ptr_eq(&prev, &current) {
+            if let Some(p) = pgid {
+                killpg(p, &server.name, "kill_server");
+            }
+            tracing::error!(
+                "MCP server '{}' killed and marked dead (reason={reason})",
+                server.name
+            );
+        }
+    }
+
+    fn kill_and_mark_dead_if_current(
+        &self,
+        server: &Server,
+        failed_client: &Arc<Client>,
+        reason: &str,
+    ) {
+        let current = server.client.load_full();
+        match &*current {
+            ClientState::Healthy {
+                client,
+                pgid,
+                tools,
+            } if Arc::ptr_eq(client, failed_client) => {
+                if let Some(p) = *pgid {
+                    killpg(p, &server.name, "call_failed");
+                }
+                let dead = Arc::new(ClientState::Dead {
+                    attempts: 1,
+                    next_retry: Instant::now() + backoff(1, self.backoff_base, self.backoff_max),
+                    reason: reason.to_owned(),
+                    tools: tools.clone(),
+                });
+                let _ = server.client.compare_and_swap(&current, dead);
+                tracing::error!(
+                    "MCP server '{}' killed and marked dead (reason={reason})",
+                    server.name
+                );
+            }
+            _ => {}
+        }
+    }
+
+    pub async fn call(
+        &self,
+        qname: &str,
+        provider_id: &str,
+        arguments: &Value,
+        max_bytes: usize,
+        cancel: &mut watch::Receiver<bool>,
+    ) -> Result<ToolResult, AgentError> {
+        let entry = self
+            .by_qname
+            .get(qname)
+            .ok_or_else(|| AgentError::Mcp(format!("unknown tool {qname}")))?;
+        let server = self.servers[entry.server_idx].clone();
+
+        let state = server.client.load();
+        if let ClientState::Healthy { client, tools, .. } = &**state {
+            if !tools.iter().any(|t| t == &entry.bare) {
+                return Err(AgentError::Mcp(format!(
+                    "tool '{qname}': no longer available; the MCP server restarted with a different tool set."
+                )));
+            }
+            let client = client.clone();
+            drop(state);
+            return self
+                .do_call(
+                    &server,
+                    &client,
+                    &entry.bare,
+                    qname,
+                    provider_id,
+                    arguments,
+                    max_bytes,
+                    cancel,
+                )
+                .await;
+        }
+        drop(state);
+
+        self.maybe_restart(&server).await?;
+        let state = server.client.load();
+        let client = match &**state {
+            ClientState::Healthy { client, tools, .. } => {
+                if !tools.iter().any(|t| t == &entry.bare) {
+                    return Err(AgentError::Mcp(format!(
+                        "tool '{qname}': no longer available; the MCP server restarted with a different tool set."
+                    )));
+                }
+                client.clone()
+            }
+            ClientState::Dead { reason, .. } => {
+                return Err(AgentError::Mcp(format!(
+                    "tool '{qname}': server '{}' restart failed: {reason}",
+                    server.name
+                )));
+            }
+        };
+        drop(state);
+        self.do_call(
+            &server,
+            &client,
+            &entry.bare,
+            qname,
+            provider_id,
+            arguments,
+            max_bytes,
+            cancel,
+        )
+        .await
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    async fn do_call(
+        &self,
+        server: &Server,
+        client: &Arc<Client>,
+        bare: &str,
+        qname: &str,
+        provider_id: &str,
+        arguments: &Value,
+        max_bytes: usize,
+        cancel: &mut watch::Receiver<bool>,
+    ) -> Result<ToolResult, AgentError> {
+        let arg_obj = match arguments {
+            Value::Object(m) => Some(m.clone()),
+            Value::Null => None,
+            _ => {
+                return Err(AgentError::Mcp(format!(
+                    "tool {qname} arguments must be a JSON object"
+                )))
+            }
+        };
+        let mut params = CallToolRequestParams::default();
+        params.name = bare.to_owned().into();
+        params.arguments = arg_obj;
+
+        use rmcp::model::{CallToolRequest, ClientRequest, ServerResult};
+        use rmcp::service::PeerRequestOptions;
+
+        let req = ClientRequest::CallToolRequest(CallToolRequest::new(params));
+        let mut handle = client
+            .peer()
+            .send_cancellable_request(req, PeerRequestOptions::no_options())
+            .await
+            .map_err(|e| AgentError::Mcp(format!("call {qname}: {e}")))?;
+
+        // Early cancel check — watch::changed() only fires on NEW writes.
+        if *cancel.borrow() {
+            fire_and_forget_cancel(handle, qname);
+            return Err(AgentError::Cancelled);
+        }
+
+        // Poll the inner oneshot directly so we can still own `handle` in
+        // the cancel branch (await_response would move it).
+        let raw: Result<ServerResult, ServiceError> = tokio::select! {
+            biased;
+            _ = cancel.changed() => {
+                fire_and_forget_cancel(handle, qname);
+                return Err(AgentError::Cancelled);
+            }
+            r = &mut handle.rx => match r {
+                Ok(inner) => inner,
+                Err(_) => Err(ServiceError::TransportClosed),
+            },
+        };
+
+        let res = match raw {
+            Ok(ServerResult::CallToolResult(r)) => r,
+            Ok(_) => {
+                return Err(AgentError::Mcp(format!(
+                    "call {qname}: unexpected response type"
+                )))
+            }
+            Err(e) => {
+                if is_transport_error(&e) {
+                    self.kill_and_mark_dead_if_current(
+                        server,
+                        client,
+                        &format!("call failed: {e}"),
+                    );
+                    return Err(AgentError::Mcp(format!("call {qname}: {e}")));
+                }
+                // Application-level JSON-RPC error (e.g. -32602 invalid params).
+                // Server is healthy — it correctly rejected bad input. Return to LLM.
+                return Ok(ToolResult {
+                    provider_id: provider_id.to_owned(),
+                    text: clamp(format!("Tool call rejected: {e}"), max_bytes),
+                    is_error: true,
+                });
+            }
+        };
+        let text = collapse_content(&res.content, max_bytes);
+        Ok(ToolResult {
+            provider_id: provider_id.to_owned(),
+            text: clamp(text, max_bytes),
+            is_error: res.is_error.unwrap_or(false),
+        })
+    }
+
+    async fn maybe_restart(&self, server: &Server) -> Result<(), AgentError> {
+        match check_restart_state(server, self.max_attempts)? {
+            RestartCheck::Healthy => return Ok(()),
+            RestartCheck::Ready { .. } => {}
+        }
+
+        let _guard = server.restart_lock.lock().await;
+
+        let (attempt_n, prev_tools) = match check_restart_state(server, self.max_attempts)? {
+            RestartCheck::Healthy => return Ok(()),
+            RestartCheck::Ready {
+                attempt_n,
+                prev_tools,
+            } => (attempt_n, prev_tools),
+        };
+
+        let started = Instant::now();
+        tracing::info!(
+            "MCP server '{}' restarting (attempt {attempt_n}/{})",
+            server.name,
+            self.max_attempts
+        );
+        match spawn_one(&server.spec, self.init_timeout).await {
+            Ok((client, pgid, tool_names, _raw_tools)) => {
+                server.client.store(Arc::new(ClientState::Healthy {
+                    client: Arc::new(client),
+                    pgid,
+                    tools: Arc::new(tool_names),
+                }));
+
+                tracing::info!(
+                    "MCP server '{}' restarted in {}ms (attempt {attempt_n})",
+                    server.name,
+                    started.elapsed().as_millis()
+                );
+                Ok(())
+            }
+            Err(e) => {
+                let reason = format!("restart failed: {e}");
+                let permanent = attempt_n >= self.max_attempts;
+                let next_retry = if permanent {
+                    Instant::now() + Duration::from_secs(86_400)
+                } else {
+                    Instant::now() + backoff(attempt_n, self.backoff_base, self.backoff_max)
+                };
+                server.client.store(Arc::new(ClientState::Dead {
+                    attempts: attempt_n,
+                    next_retry,
+                    reason: reason.clone(),
+                    tools: prev_tools,
+                }));
+
+                tracing::error!(
+                    "MCP server '{}' restart failed (attempt {attempt_n}/{}, permanent={permanent}): {reason}",
+                    server.name, self.max_attempts
+                );
+                Err(AgentError::Mcp(reason))
+            }
+        }
+    }
+}
+
+async fn spawn_one(
+    spec: &ServerSpec,
+    timeout: Duration,
+) -> Result<(Client, Option<u32>, Vec<String>, Vec<rmcp::model::Tool>), AgentError> {
+    let mut cmd = Command::new(&spec.command);
+    cmd.args(&spec.args);
+    cmd.env_clear();
+    for k in PASSTHROUGH_ENV {
+        if let Ok(v) = std::env::var(k) {
+            cmd.env(k, v);
+        }
+    }
+    for (k, v) in &spec.env {
+        cmd.env(k, v);
+    }
+    cmd.current_dir(&spec.cwd);
+    cmd.stderr(std::process::Stdio::inherit());
+
+    #[cfg(unix)]
+    cmd.process_group(0);
+
+    let transport = TokioChildProcess::new(cmd)
+        .map_err(|e| AgentError::Mcp(format!("spawn {}: {e}", spec.name)))?;
+    let pgid = transport.id();
+
+    struct PgidGuard {
+        pgid: Option<u32>,
+        name: String,
+    }
+    impl Drop for PgidGuard {
+        fn drop(&mut self) {
+            if let Some(p) = self.pgid.take() {
+                killpg(p, &self.name, "spawn_dropped");
+            }
+        }
+    }
+    let mut guard = PgidGuard {
+        pgid,
+        name: spec.name.clone(),
+    };
+
+    let client: Client = match tokio::time::timeout(timeout, ().serve(transport)).await {
+        Ok(Ok(c)) => c,
+        Ok(Err(e)) => {
+            return Err(AgentError::Mcp(format!("init {}: {e}", spec.name)));
+        }
+        Err(_) => {
+            return Err(AgentError::Mcp(timeout_msg("init", &spec.name, timeout)));
+        }
+    };
+
+    let tools = match tokio::time::timeout(timeout, client.peer().list_all_tools()).await {
+        Ok(Ok(t)) => t,
+        Ok(Err(e)) => {
+            return Err(AgentError::Mcp(format!("list_tools {}: {e}", spec.name)));
+        }
+        Err(_) => {
+            return Err(AgentError::Mcp(timeout_msg(
+                "list_tools",
+                &spec.name,
+                timeout,
+            )));
+        }
+    };
+    let names: Vec<String> = tools.iter().map(|t| t.name.to_string()).collect();
+    guard.pgid = None;
+    Ok((client, pgid, names, tools))
+}
+
+/// Send `notifications/cancelled` to the MCP server, fire-and-forget.
+/// Per MCP spec, cancellation notifications are best-effort; we never
+/// block the agent on slow server stdio.
+fn fire_and_forget_cancel(
+    handle: rmcp::service::RequestHandle<rmcp::service::RoleClient>,
+    qname: &str,
+) {
+    let qname_owned = qname.to_owned();
+    tokio::spawn(async move {
+        if let Err(e) = handle.cancel(Some("session cancelled".into())).await {
+            tracing::debug!("cancel notification failed for {qname_owned}: {e}");
+        }
+    });
+}
+
+/// Returns `true` for errors indicating the MCP server process is dead or
+/// unreachable. Returns `false` for application-level JSON-RPC errors where
+/// the server is healthy but rejected the request (e.g. invalid params).
+fn is_transport_error(e: &ServiceError) -> bool {
+    matches!(
+        e,
+        ServiceError::TransportSend(_)
+            | ServiceError::TransportClosed
+            | ServiceError::Timeout { .. }
+            | ServiceError::UnexpectedResponse
+    )
+}
+
+fn backoff(attempt: u32, base: Duration, max: Duration) -> Duration {
+    let shift = attempt.saturating_sub(1).min(20);
+    let scaled = base.saturating_mul(1u32 << shift);
+    let capped = scaled.min(max);
+    let ms = capped.as_millis() as u64;
+    let jitter_pct = jitter_percent();
+    let jittered = (ms as i64) + ((ms as i64) * jitter_pct / 100);
+    Duration::from_millis(jittered.max(0) as u64)
+}
+
+fn jitter_percent() -> i64 {
+    let mut buf = [0u8; 1];
+    let _ = getrandom::getrandom(&mut buf);
+    ((buf[0] as i64) % 41) - 20
+}
+
+fn timeout_msg(stage: &str, name: &str, t: Duration) -> String {
+    format!("{stage} {name}: timeout after {}s", t.as_secs())
+}
+
+fn cap_schema(qname: &str, schema: Value) -> Value {
+    let size = serde_json::to_vec(&schema).map(|b| b.len()).unwrap_or(0);
+    if size <= MAX_SCHEMA_BYTES {
+        return schema;
+    }
+    tracing::warn!(
+        "tool {qname} schema is {size} bytes (>{MAX_SCHEMA_BYTES}); replacing with empty object"
+    );
+    Value::Object(Map::new())
+}
+
+#[cfg(unix)]
+fn killpg(pgid: u32, name: &str, stage: &str) {
+    use nix::sys::signal::{killpg as nix_killpg, Signal};
+    use nix::unistd::Pid;
+    let result = nix_killpg(Pid::from_raw(pgid as i32), Signal::SIGKILL);
+    tracing::info!(
+        "killpg MCP {name} ({stage}) pgid={pgid} ok={}",
+        result.is_ok()
+    );
+}
+#[cfg(not(unix))]
+fn killpg(_pgid: u32, name: &str, stage: &str) {
+    tracing::info!("relying on Drop to kill MCP {name} ({stage})");
+}
+
+fn valid_name(s: &str) -> bool {
+    !s.is_empty()
+        && s.len() <= MAX_NAME_LEN
+        && s.bytes()
+            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
+}
+
+fn truncate_at_boundary(s: &str, max: usize) -> &str {
+    if s.len() <= max {
+        return s;
+    }
+    let mut cut = max;
+    while cut > 0 && !s.is_char_boundary(cut) {
+        cut -= 1;
+    }
+    &s[..cut]
+}
+
+fn push_bounded(out: &mut String, s: &str, max: usize) {
+    let remaining = max.saturating_sub(out.len());
+    if remaining > 0 {
+        out.push_str(truncate_at_boundary(s, remaining));
+    }
+}
+
+fn collapse_content(blocks: &[rmcp::model::Content], max_bytes: usize) -> String {
+    use rmcp::model::RawContent;
+    let mut out = String::new();
+    let mut truncated = false;
+    let short = |s: &str| truncate_at_boundary(s, MARKER_FIELD_MAX).to_owned();
+    for c in blocks {
+        if out.len() >= max_bytes {
+            truncated = true;
+            break;
+        }
+        if !out.is_empty() {
+            push_bounded(&mut out, "\n", max_bytes);
+        }
+        let chunk: String = match &c.raw {
+            RawContent::Text(t) => t.text.clone(),
+            RawContent::Image(i) => {
+                format!(
+                    "[image elided: {}, {} bytes]",
+                    short(&i.mime_type),
+                    i.data.len()
+                )
+            }
+            RawContent::Audio(a) => {
+                format!(
+                    "[audio elided: {}, {} bytes]",
+                    short(&a.mime_type),
+                    a.data.len()
+                )
+            }
+            RawContent::ResourceLink(r) => format!("[resource: {}]", short(&r.uri)),
+            RawContent::Resource(_) => "[resource elided]".into(),
+        };
+        let before = out.len();
+        push_bounded(&mut out, &chunk, max_bytes);
+        if out.len() - before < chunk.len() {
+            truncated = true;
+        }
+    }
+    if truncated {
+        out.push_str("\n[content truncated]");
+    }
+    out
+}
diff --git a/crates/sprout-agent/src/types.rs b/crates/sprout-agent/src/types.rs
new file mode 100644
index 0000000000..d05ec51cc5
--- /dev/null
+++ b/crates/sprout-agent/src/types.rs
@@ -0,0 +1,170 @@
+use serde::Deserialize;
+use serde_json::Value;
+
+#[derive(Debug, Clone)]
+pub enum HistoryItem {
+    User(String),
+    Assistant {
+        text: String,
+        tool_calls: Vec<ToolCall>,
+    },
+    ToolResult(ToolResult),
+}
+
+impl HistoryItem {
+    pub fn estimated_bytes(&self) -> usize {
+        match self {
+            Self::User(s) => s.len(),
+            Self::Assistant { text, tool_calls } => {
+                text.len()
+                    + tool_calls
+                        .iter()
+                        .map(|c| {
+                            c.provider_id.len()
+                                + c.name.len()
+                                + serde_json::to_vec(&c.arguments)
+                                    .map(|b| b.len())
+                                    .unwrap_or(0)
+                        })
+                        .sum::<usize>()
+            }
+            Self::ToolResult(r) => r.provider_id.len() + r.text.len(),
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct ToolCall {
+    pub provider_id: String,
+    pub name: String,
+    pub arguments: Value,
+}
+
+#[derive(Debug, Clone)]
+pub struct ToolResult {
+    pub provider_id: String,
+    pub text: String,
+    pub is_error: bool,
+}
+
+#[derive(Debug, Clone)]
+pub struct LlmResponse {
+    pub text: String,
+    pub tool_calls: Vec<ToolCall>,
+    pub stop: ProviderStop,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum ProviderStop {
+    EndTurn,
+    ToolUse,
+    MaxTokens,
+    Refusal,
+    Other,
+}
+
+#[derive(Debug, Clone)]
+pub struct ToolDef {
+    pub name: String,
+    pub description: String,
+    pub input_schema: Value,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum StopReason {
+    EndTurn,
+    Cancelled,
+    MaxTokens,
+    MaxTurnRequests,
+    Refusal,
+}
+
+impl StopReason {
+    pub fn as_wire(self) -> &'static str {
+        match self {
+            Self::EndTurn => "end_turn",
+            Self::Cancelled => "cancelled",
+            Self::MaxTokens => "max_tokens",
+            Self::MaxTurnRequests => "max_turn_requests",
+            Self::Refusal => "refusal",
+        }
+    }
+}
+
+#[derive(Debug, Deserialize, Clone)]
+pub struct McpServerStdio {
+    pub name: String,
+    pub command: String,
+    #[serde(default)]
+    pub args: Vec<String>,
+    #[serde(default)]
+    pub env: Vec<EnvVar>,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+pub struct EnvVar {
+    pub name: String,
+    pub value: String,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum ContentBlock {
+    Text {
+        text: String,
+    },
+    ResourceLink {
+        uri: String,
+    },
+    #[serde(other)]
+    Unsupported,
+}
+
+#[derive(Debug)]
+pub enum AgentError {
+    InvalidParams(String),
+    Llm(String),
+    LlmAuth(String),
+    Mcp(String),
+    Cancelled,
+}
+
+impl std::fmt::Display for AgentError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::InvalidParams(s) => write!(f, "invalid params: {s}"),
+            Self::Llm(s) => write!(f, "llm: {s}"),
+            Self::LlmAuth(s) => write!(f, "llm auth: {s}"),
+            Self::Mcp(s) => write!(f, "mcp: {s}"),
+            Self::Cancelled => write!(f, "cancelled"),
+        }
+    }
+}
+
+impl std::error::Error for AgentError {}
+
+impl AgentError {
+    pub fn json_rpc_code(&self) -> i32 {
+        match self {
+            Self::InvalidParams(_) => -32602,
+            _ => -32000,
+        }
+    }
+}
+
+pub fn clamp(mut s: String, max: usize) -> String {
+    if s.len() <= max {
+        return s;
+    }
+    const MARKER: &str = "\n[truncated]";
+    let budget = max.saturating_sub(MARKER.len());
+    let mut cut = budget;
+    while cut > 0 && !s.is_char_boundary(cut) {
+        cut -= 1;
+    }
+    s.truncate(cut);
+    if max >= MARKER.len() {
+        s.push_str(MARKER);
+    }
+    s
+}
diff --git a/crates/sprout-agent/src/wire.rs b/crates/sprout-agent/src/wire.rs
new file mode 100644
index 0000000000..60494b63a2
--- /dev/null
+++ b/crates/sprout-agent/src/wire.rs
@@ -0,0 +1,180 @@
+use serde::Deserialize;
+use serde_json::{json, Value};
+use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt};
+use tokio::sync::mpsc;
+
+use crate::types::{ContentBlock, McpServerStdio};
+
+pub const PARSE_ERROR: i32 = -32700;
+pub const INVALID_REQUEST: i32 = -32600;
+pub const METHOD_NOT_FOUND: i32 = -32601;
+pub const INVALID_PARAMS: i32 = -32602;
+
+pub enum WireMsg {
+    Notify(Value),
+}
+
+pub type WireSender = mpsc::Sender<WireMsg>;
+
+#[derive(Debug)]
+pub enum Inbound {
+    Request {
+        id: Value,
+        method: String,
+        params: Value,
+    },
+    Notification {
+        method: String,
+        params: Value,
+    },
+    Ignored,
+    Invalid {
+        id: Value,
+        code: i32,
+        message: String,
+    },
+}
+
+#[derive(Debug, Deserialize)]
+pub struct InitializeParams {
+    #[serde(rename = "protocolVersion")]
+    pub protocol_version: u32,
+    #[serde(default, rename = "clientCapabilities")]
+    pub _client_capabilities: Value,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SessionNewParams {
+    pub cwd: String,
+    #[serde(default)]
+    pub mcp_servers: Vec<McpServerStdio>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SessionPromptParams {
+    pub session_id: String,
+    pub prompt: Vec<ContentBlock>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SessionCancelParams {
+    pub session_id: String,
+}
+
+pub fn classify(msg: &Value) -> Inbound {
+    if !msg.is_object() || msg.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
+        return Inbound::Invalid {
+            id: msg.get("id").cloned().unwrap_or(Value::Null),
+            code: INVALID_REQUEST,
+            message: "jsonrpc: missing or invalid version".into(),
+        };
+    }
+    let id = msg.get("id").cloned();
+    let method = msg.get("method").and_then(Value::as_str).map(str::to_owned);
+    let params = msg.get("params").cloned().unwrap_or(Value::Null);
+
+    match (method, id) {
+        (Some(m), Some(id)) => Inbound::Request {
+            id,
+            method: m,
+            params,
+        },
+        (Some(m), None) => Inbound::Notification { method: m, params },
+        // Bare responses (id present, no method) are unexpected — sprout-agent
+        // does not issue requests to the client. Ignore silently.
+        (None, Some(_)) => Inbound::Ignored,
+        (None, None) => Inbound::Invalid {
+            id: Value::Null,
+            code: INVALID_REQUEST,
+            message: "jsonrpc: missing method and id".into(),
+        },
+    }
+}
+
+pub fn ok(id: Value, result: Value) -> Value {
+    json!({ "jsonrpc": "2.0", "id": id, "result": result })
+}
+
+pub fn err(id: Value, code: i32, message: &str) -> Value {
+    json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
+}
+
+pub fn session_update(sid: &str, update: Value) -> Value {
+    json!({
+        "jsonrpc": "2.0",
+        "method": "session/update",
+        "params": { "sessionId": sid, "update": update },
+    })
+}
+
+pub async fn send(wire: &WireSender, msg: Value) {
+    let _ = wire.send(WireMsg::Notify(msg)).await;
+}
+
+pub async fn read_bounded_line<R: AsyncBufRead + Unpin>(
+    stdin: &mut R,
+    max: usize,
+) -> std::io::Result<Option<String>> {
+    let mut buf: Vec<u8> = Vec::new();
+    loop {
+        let chunk = stdin.fill_buf().await?;
+        if chunk.is_empty() {
+            if !buf.is_empty() {
+                tracing::error!(
+                    "io: unterminated frame at EOF ({} bytes dropped)",
+                    buf.len()
+                );
+            }
+            return Ok(None);
+        }
+        let take = chunk
+            .iter()
+            .position(|b| *b == b'\n')
+            .map_or(chunk.len(), |i| i + 1);
+        if buf.len().saturating_add(take) > max {
+            return Err(std::io::Error::new(
+                std::io::ErrorKind::InvalidData,
+                format!("io: line exceeds max ({max} bytes)"),
+            ));
+        }
+        buf.extend_from_slice(&chunk[..take]);
+        stdin.consume(take);
+        if buf.ends_with(b"\n") {
+            buf.pop();
+            if buf.ends_with(b"\r") {
+                buf.pop();
+            }
+            match String::from_utf8(buf) {
+                Ok(s) => return Ok(Some(s)),
+                Err(_) => {
+                    return Err(std::io::Error::new(
+                        std::io::ErrorKind::InvalidData,
+                        "io: frame contains invalid UTF-8",
+                    ))
+                }
+            }
+        }
+    }
+}
+
+pub async fn writer_task(mut rx: mpsc::Receiver<WireMsg>) {
+    let mut stdout = tokio::io::stdout();
+    while let Some(msg) = rx.recv().await {
+        let WireMsg::Notify(v) = msg;
+        let mut s = match serde_json::to_string(&v) {
+            Ok(s) => s,
+            Err(e) => {
+                tracing::error!("io: serialize: {e}");
+                continue;
+            }
+        };
+        s.push('\n');
+        if stdout.write_all(s.as_bytes()).await.is_err() {
+            return;
+        }
+        let _ = stdout.flush().await;
+    }
+}
diff --git a/crates/sprout-agent/tests/bin/fake_mcp.rs b/crates/sprout-agent/tests/bin/fake_mcp.rs
new file mode 100644
index 0000000000..47dfa56d7b
--- /dev/null
+++ b/crates/sprout-agent/tests/bin/fake_mcp.rs
@@ -0,0 +1,291 @@
+//! Tiny fake MCP server for integration tests.
+//!
+//! Reads JSON-RPC line frames on stdin and replies on stdout. Driven by
+//! environment variables so tests can simulate misbehavior:
+//!
+//!   FAKE_MCP_HANG_INIT=1     — never reply to `initialize` (init timeout)
+//!   FAKE_MCP_HANG_TOOLS=1    — never reply to `tools/list` (list timeout)
+//!   FAKE_MCP_TOOL_COUNT=N    — return N tools (default: 1)
+//!   FAKE_MCP_HUGE_DESC=1     — every tool description is 100 KB
+//!   FAKE_MCP_DESC_SIZE=N     — every tool description is N bytes (overrides HUGE_DESC)
+//!   FAKE_MCP_TOOL_DELAY=N    — `tools/call` sleeps N seconds before replying
+//!                              (use a large value, e.g. 999, to simulate hang)
+//!   FAKE_MCP_PID_FILE=path   — write the child PID to `path` on startup
+//!                              (for tests that want to verify the child died)
+//!   FAKE_MCP_SPAWN_GRANDCHILD=1
+//!                            — on `tools/call`, spawn a `sleep 999`
+//!                              grandchild before hanging. Its PID is
+//!                              written to FAKE_MCP_GRANDCHILD_PID_FILE
+//!                              so a test can verify the entire process
+//!                              tree dies on timeout.
+//!   FAKE_MCP_GRANDCHILD_PID_FILE=path
+//!                            — path to write the grandchild PID to.
+//!   FAKE_MCP_STOP_HOOK=1     — expose a `_Stop` hook tool
+//!   FAKE_MCP_STOP_TEXT=text  — `_Stop` returns this text (default: "keep going")
+//!   FAKE_MCP_STOP_DELAY=N    — `_Stop` sleeps N seconds before replying
+//!                              (use a large value to simulate hang/timeout)
+//!   FAKE_MCP_STOP_COUNT=N    — `_Stop` returns STOP_TEXT for the first N
+//!                              invocations; empty string thereafter. If
+//!                              unset, every call returns STOP_TEXT.
+//!   FAKE_MCP_POSTCOMPACT_HOOK=1
+//!                            — expose a `_PostCompact` hook tool
+//!   FAKE_MCP_POSTCOMPACT_TEXT=text
+//!                            — `_PostCompact` returns this (default: "")
+
+use std::io::{BufRead, Write};
+
+use serde_json::{json, Value};
+
+fn env_flag(k: &str) -> bool {
+    std::env::var(k).map(|v| v != "0").unwrap_or(false)
+}
+
+fn env_usize(k: &str, default: usize) -> usize {
+    std::env::var(k)
+        .ok()
+        .and_then(|v| v.parse().ok())
+        .unwrap_or(default)
+}
+
+fn env_u64(k: &str, default: u64) -> u64 {
+    std::env::var(k)
+        .ok()
+        .and_then(|v| v.parse().ok())
+        .unwrap_or(default)
+}
+
+fn write_response(id: Value, result: Value) {
+    let msg = json!({ "jsonrpc": "2.0", "id": id, "result": result });
+    let mut s = serde_json::to_string(&msg).expect("serialize");
+    s.push('\n');
+    let mut out = std::io::stdout().lock();
+    out.write_all(s.as_bytes()).expect("write");
+    out.flush().expect("flush");
+}
+
+fn hang_forever() -> ! {
+    loop {
+        std::thread::sleep(std::time::Duration::from_secs(60));
+    }
+}
+
+fn make_tools(
+    count: usize,
+    desc: &str,
+    include_stop_hook: bool,
+    include_post_compact_hook: bool,
+) -> Vec<Value> {
+    let mut tools: Vec<Value> = (0..count)
+        .map(|i| {
+            json!({
+                "name": format!("tool_{i}"),
+                "description": desc,
+                "inputSchema": { "type": "object", "properties": {} },
+            })
+        })
+        .collect();
+    if include_stop_hook {
+        tools.push(json!({
+            "name": "_Stop",
+            "description": "stop hook",
+            "inputSchema": { "type": "object", "properties": {} },
+        }));
+    }
+    if include_post_compact_hook {
+        tools.push(json!({
+            "name": "_PostCompact",
+            "description": "post compact hook",
+            "inputSchema": { "type": "object", "properties": {} },
+        }));
+    }
+    tools
+}
+
+fn main() {
+    // Optional: write our own PID so a test can later check the process is gone.
+    if let Ok(path) = std::env::var("FAKE_MCP_PID_FILE") {
+        let pid = std::process::id().to_string();
+        let _ = std::fs::write(&path, pid);
+    }
+
+    let hang_init = env_flag("FAKE_MCP_HANG_INIT");
+    let hang_tools = env_flag("FAKE_MCP_HANG_TOOLS");
+    let tool_count = env_usize("FAKE_MCP_TOOL_COUNT", 1);
+    // FAKE_MCP_DESC_SIZE wins over FAKE_MCP_HUGE_DESC when set.
+    let desc: String = if let Some(n) = std::env::var("FAKE_MCP_DESC_SIZE")
+        .ok()
+        .and_then(|v| v.parse::<usize>().ok())
+    {
+        "x".repeat(n)
+    } else if env_flag("FAKE_MCP_HUGE_DESC") {
+        "x".repeat(100_000)
+    } else {
+        "fake tool".to_owned()
+    };
+    let tool_delay_secs = env_u64("FAKE_MCP_TOOL_DELAY", 0);
+    let stop_hook = env_flag("FAKE_MCP_STOP_HOOK");
+    let stop_text = std::env::var("FAKE_MCP_STOP_TEXT").unwrap_or_else(|_| "keep going".to_owned());
+    let stop_delay_secs = env_u64("FAKE_MCP_STOP_DELAY", 0);
+    // 0 means "unset" → unlimited; any positive value caps the number of
+    // calls that return STOP_TEXT before flipping to empty string.
+    let stop_count_limit: usize = env_usize("FAKE_MCP_STOP_COUNT", usize::MAX);
+    let mut stop_calls_seen: usize = 0;
+    let post_compact_hook = env_flag("FAKE_MCP_POSTCOMPACT_HOOK");
+    let post_compact_text = std::env::var("FAKE_MCP_POSTCOMPACT_TEXT").unwrap_or_default();
+
+    // Use a channel-based stdin reader so notifications (which carry no id)
+    // are captured even while the main thread is sleeping during a tool call.
+    let cancel_log_path = std::env::var("FAKE_MCP_CANCEL_LOG").ok();
+    let (tx, rx) = std::sync::mpsc::channel::<(Value, Option<Value>)>();
+    let cancel_log_for_thread = cancel_log_path.clone();
+    std::thread::spawn(move || {
+        let stdin = std::io::stdin();
+        let lines = stdin.lock().lines();
+        for line in lines {
+            let line = match line {
+                Ok(l) => l,
+                Err(_) => return,
+            };
+            if line.trim().is_empty() {
+                continue;
+            }
+            let msg: Value = match serde_json::from_str(&line) {
+                Ok(v) => v,
+                Err(_) => continue,
+            };
+            let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
+            let id = msg.get("id").cloned();
+            // Notifications carry no id. Log cancellations if configured.
+            if id.is_none() || id == Some(Value::Null) {
+                if method == "notifications/cancelled" {
+                    if let Some(ref path) = cancel_log_for_thread {
+                        use std::io::Write as _;
+                        if let Ok(mut f) = std::fs::OpenOptions::new()
+                            .create(true)
+                            .append(true)
+                            .open(path)
+                        {
+                            let _ = writeln!(f, "{}", line.trim());
+                        }
+                    }
+                }
+                continue;
+            }
+            // Send requests (with id) to the main processing loop.
+            let _ = tx.send((msg, id));
+        }
+    });
+
+    while let Ok((msg, id_opt)) = rx.recv() {
+        let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
+        let id = id_opt.unwrap_or(Value::Null);
+
+        match method {
+            "initialize" => {
+                if hang_init {
+                    hang_forever();
+                }
+                write_response(
+                    id,
+                    json!({
+                        "protocolVersion": "2025-06-18",
+                        "capabilities": { "tools": {} },
+                        "serverInfo": { "name": "fake-mcp", "version": "0.0.0" },
+                    }),
+                );
+            }
+            "tools/list" => {
+                if hang_tools {
+                    hang_forever();
+                }
+                write_response(
+                    id,
+                    json!({
+                        "tools": make_tools(tool_count, &desc, stop_hook, post_compact_hook)
+                    }),
+                );
+            }
+            "tools/call" => {
+                // Signal that the request was received (for tests that
+                // need to wait until the call is in-flight before cancelling).
+                // Write the request id so tests can correlate with cancel.
+                if let Ok(path) = std::env::var("FAKE_MCP_CALL_RECEIVED") {
+                    let id_str = serde_json::to_string(&id).unwrap_or_else(|_| "?".into());
+                    let _ = std::fs::write(&path, id_str);
+                }
+                let called_name = msg
+                    .get("params")
+                    .and_then(|p| p.get("name"))
+                    .and_then(Value::as_str)
+                    .unwrap_or("");
+                // Optionally spawn a long-sleeping grandchild so the test
+                // can verify process-group killing reaches the whole tree.
+                if env_flag("FAKE_MCP_SPAWN_GRANDCHILD") {
+                    let child = std::process::Command::new("sleep")
+                        .arg("999")
+                        .spawn()
+                        .expect("spawn grandchild");
+                    if let Ok(path) = std::env::var("FAKE_MCP_GRANDCHILD_PID_FILE") {
+                        let _ = std::fs::write(&path, child.id().to_string());
+                    }
+                    std::mem::forget(child);
+                }
+                if called_name == "_Stop" {
+                    if stop_delay_secs > 0 {
+                        std::thread::sleep(std::time::Duration::from_secs(stop_delay_secs));
+                    }
+                    // Once we exceed the configured count, return empty
+                    // text so the agent treats it as no objection. This
+                    // lets a test exercise the "objected then cleared"
+                    // path without relying on the rejection budget.
+                    let payload = if stop_calls_seen < stop_count_limit {
+                        stop_text.clone()
+                    } else {
+                        String::new()
+                    };
+                    stop_calls_seen = stop_calls_seen.saturating_add(1);
+                    write_response(
+                        id,
+                        json!({
+                            "content": [{ "type": "text", "text": payload }],
+                            "isError": false,
+                        }),
+                    );
+                    continue;
+                }
+                if called_name == "_PostCompact" {
+                    write_response(
+                        id,
+                        json!({
+                            "content": [{ "type": "text", "text": post_compact_text }],
+                            "isError": false,
+                        }),
+                    );
+                    continue;
+                }
+                if tool_delay_secs > 0 {
+                    std::thread::sleep(std::time::Duration::from_secs(tool_delay_secs));
+                }
+                write_response(
+                    id,
+                    json!({
+                        "content": [{ "type": "text", "text": "ok" }],
+                        "isError": false,
+                    }),
+                );
+            }
+            _ => {
+                // Unknown method: respond with an error so rmcp doesn't hang.
+                let err = json!({
+                    "jsonrpc": "2.0", "id": id,
+                    "error": { "code": -32601, "message": format!("method not found: {method}") },
+                });
+                let mut s = serde_json::to_string(&err).unwrap();
+                s.push('\n');
+                let mut out = std::io::stdout().lock();
+                let _ = out.write_all(s.as_bytes());
+                let _ = out.flush();
+            }
+        }
+    }
+}
diff --git a/crates/sprout-agent/tests/fake_llm.rs b/crates/sprout-agent/tests/fake_llm.rs
new file mode 100644
index 0000000000..8e07b2a771
--- /dev/null
+++ b/crates/sprout-agent/tests/fake_llm.rs
@@ -0,0 +1,337 @@
+//! Integration test: fake LLM HTTP server + sprout-agent subprocess.
+//!
+//! Drives the agent through the ACP wire protocol and verifies:
+//!   - initialize / session/new responses
+//!   - tool_call (pending) → request_permission → tool_call_update
+//!   - session/prompt response with stopReason=end_turn
+//!   - concurrent prompt rejection
+
+use std::collections::VecDeque;
+use std::process::Stdio;
+use std::sync::Arc;
+use std::time::Duration;
+
+use serde_json::{json, Value};
+use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
+use tokio::net::TcpListener;
+use tokio::sync::Mutex;
+
+// ─── Fake LLM server ────────────────────────────────────────────────────────
+
+async fn spawn_fake_llm(responses: Vec<Value>) -> String {
+    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let url = format!("http://{}", listener.local_addr().unwrap());
+    let queue = Arc::new(Mutex::new(VecDeque::from(responses)));
+    tokio::spawn(async move {
+        loop {
+            let (mut sock, _) = match listener.accept().await {
+                Ok(p) => p,
+                Err(_) => return,
+            };
+            let queue = queue.clone();
+            tokio::spawn(async move {
+                let mut buf = Vec::new();
+                let mut tmp = [0u8; 4096];
+                while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
+                    match sock.read(&mut tmp).await {
+                        Ok(0) | Err(_) => return,
+                        Ok(n) => buf.extend_from_slice(&tmp[..n]),
+                    }
+                    if buf.len() > 1_000_000 {
+                        return;
+                    }
+                }
+                let body = queue
+                    .lock()
+                    .await
+                    .pop_front()
+                    .unwrap_or_else(|| json!({ "error": "no canned response" }));
+                let body_s = serde_json::to_string(&body).unwrap();
+                let resp = format!(
+                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
+                    body_s.len(), body_s,
+                );
+                let _ = sock.write_all(resp.as_bytes()).await;
+                let _ = sock.shutdown().await;
+            });
+        }
+    });
+    url
+}
+
+// ─── ACP harness ────────────────────────────────────────────────────────────
+
+struct Harness {
+    child: tokio::process::Child,
+    stdin: tokio::process::ChildStdin,
+    stdout: BufReader<tokio::process::ChildStdout>,
+    next_id: i64,
+}
+
+impl Harness {
+    async fn spawn(base_url: &str) -> Self {
+        let bin = env!("CARGO_BIN_EXE_sprout-agent");
+        let mut cmd = tokio::process::Command::new(bin);
+        cmd.env("SPROUT_AGENT_PROVIDER", "openai")
+            .env("OPENAI_COMPAT_API_KEY", "test")
+            .env("OPENAI_COMPAT_MODEL", "fake-model")
+            .env("OPENAI_COMPAT_BASE_URL", base_url)
+            .env("SPROUT_AGENT_LLM_TIMEOUT_SECS", "5")
+            .env("SPROUT_AGENT_TOOL_TIMEOUT_SECS", "5")
+            .env("SPROUT_AGENT_MAX_ROUNDS", "4")
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            .stderr(Stdio::inherit())
+            .kill_on_drop(true);
+        let mut child = cmd.spawn().expect("spawn sprout-agent");
+        let stdin = child.stdin.take().unwrap();
+        let stdout = BufReader::new(child.stdout.take().unwrap());
+        Self {
+            child,
+            stdin,
+            stdout,
+            next_id: 1,
+        }
+    }
+
+    async fn send(&mut self, method: &str, params: Value) -> i64 {
+        let id = self.next_id;
+        self.next_id += 1;
+        self.write(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }))
+            .await;
+        id
+    }
+
+    async fn write(&mut self, msg: Value) {
+        let mut s = serde_json::to_string(&msg).unwrap();
+        s.push('\n');
+        self.stdin.write_all(s.as_bytes()).await.unwrap();
+        self.stdin.flush().await.unwrap();
+    }
+
+    async fn recv(&mut self) -> Value {
+        let mut line = String::new();
+        let n = tokio::time::timeout(Duration::from_secs(10), self.stdout.read_line(&mut line))
+            .await
+            .expect("recv timeout")
+            .expect("read line");
+        assert!(n > 0, "agent EOF");
+        serde_json::from_str(&line).expect("non-JSON line")
+    }
+
+    /// Read messages until one matches `pred`.
+    async fn recv_until<F: FnMut(&Value) -> bool>(&mut self, mut pred: F) -> Value {
+        loop {
+            let v = self.recv().await;
+            if pred(&v) {
+                return v;
+            }
+        }
+    }
+
+    async fn shutdown(mut self) {
+        drop(self.stdin);
+        let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await;
+        let _ = self.child.start_kill();
+    }
+}
+
+// ─── Canned LLM responses (OpenAI-compat shape) ─────────────────────────────
+
+fn openai_text(content: &str) -> Value {
+    json!({
+        "id": "cc-1", "object": "chat.completion", "model": "fake-model",
+        "choices": [{
+            "index": 0,
+            "message": { "role": "assistant", "content": content },
+            "finish_reason": "stop",
+        }],
+    })
+}
+
+fn openai_tool_call(id: &str, name: &str, args: Value) -> Value {
+    json!({
+        "id": "cc-2", "object": "chat.completion", "model": "fake-model",
+        "choices": [{
+            "index": 0,
+            "message": {
+                "role": "assistant", "content": null,
+                "tool_calls": [{
+                    "id": id, "type": "function",
+                    "function": { "name": name, "arguments": args.to_string() },
+                }],
+            },
+            "finish_reason": "tool_calls",
+        }],
+    })
+}
+
+async fn init_session(h: &mut Harness) -> String {
+    h.send(
+        "initialize",
+        json!({"protocolVersion":1,"clientCapabilities":{}}),
+    )
+    .await;
+    let r = h.recv().await;
+    assert_eq!(r["result"]["protocolVersion"], 1);
+    assert_eq!(r["result"]["agentInfo"]["name"], "sprout-agent");
+    h.send("session/new", json!({"cwd":"/tmp","mcpServers":[]}))
+        .await;
+    let r = h.recv().await;
+    let sid = r["result"]["sessionId"].as_str().unwrap().to_owned();
+    assert!(sid.starts_with("ses_"));
+    sid
+}
+
+// ─── Tests ──────────────────────────────────────────────────────────────────
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn text_only_end_turn() {
+    let url = spawn_fake_llm(vec![openai_text("done")]).await;
+    let mut h = Harness::spawn(&url).await;
+    let sid = init_session(&mut h).await;
+    let p_id = h
+        .send(
+            "session/prompt",
+            json!({
+                "sessionId": sid,
+                "prompt": [{ "type": "text", "text": "hi" }],
+            }),
+        )
+        .await;
+    let v = h.recv_until(|v| v["id"] == json!(p_id)).await;
+    assert_eq!(v["result"]["stopReason"], "end_turn");
+    h.shutdown().await;
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn tool_call_then_end_turn() {
+    // Round 1: tool call (will fail with "unknown tool" since no MCP registered).
+    // Round 2: text response → end_turn.
+    let url = spawn_fake_llm(vec![
+        openai_tool_call("call_xyz", "fake__do_thing", json!({"foo": "bar"})),
+        openai_text("ok"),
+    ])
+    .await;
+    let mut h = Harness::spawn(&url).await;
+    let sid = init_session(&mut h).await;
+    let p_id = h
+        .send(
+            "session/prompt",
+            json!({
+                "sessionId": sid,
+                "prompt": [{"type":"text","text":"do something"}],
+            }),
+        )
+        .await;
+
+    // Tool unknown: agent emits failed tool_call_update directly (no permission ask).
+    let v = h
+        .recv_until(|v| {
+            v.get("method") == Some(&json!("session/update"))
+                && v["params"]["update"]["sessionUpdate"] == "tool_call_update"
+                && v["params"]["update"]["status"] == "failed"
+        })
+        .await;
+    assert_eq!(v["params"]["update"]["toolCallId"], "call_xyz");
+
+    // Final response.
+    let v = h.recv_until(|v| v["id"] == json!(p_id)).await;
+    assert_eq!(v["result"]["stopReason"], "end_turn");
+    h.shutdown().await;
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn rejects_concurrent_prompts() {
+    // Slow first response so the second prompt arrives mid-flight.
+    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let url = format!("http://{}", listener.local_addr().unwrap());
+    tokio::spawn(async move {
+        let (mut sock, _) = listener.accept().await.unwrap();
+        let mut buf = Vec::new();
+        let mut tmp = [0u8; 4096];
+        while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
+            let n = sock.read(&mut tmp).await.unwrap_or(0);
+            if n == 0 {
+                return;
+            }
+            buf.extend_from_slice(&tmp[..n]);
+        }
+        tokio::time::sleep(Duration::from_millis(500)).await;
+        let body = openai_text("done").to_string();
+        let resp = format!(
+            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
+            body.len(),
+            body
+        );
+        let _ = sock.write_all(resp.as_bytes()).await;
+        let _ = sock.shutdown().await;
+    });
+
+    let mut h = Harness::spawn(&url).await;
+    let sid = init_session(&mut h).await;
+    let p1 = h
+        .send(
+            "session/prompt",
+            json!({
+                "sessionId": sid, "prompt": [{"type":"text","text":"go"}],
+            }),
+        )
+        .await;
+    tokio::time::sleep(Duration::from_millis(50)).await;
+    let p2 = h
+        .send(
+            "session/prompt",
+            json!({
+                "sessionId": sid, "prompt": [{"type":"text","text":"go again"}],
+            }),
+        )
+        .await;
+
+    let mut saw_p2_err = false;
+    let mut saw_p1_ok = false;
+    for _ in 0..10 {
+        let v = h.recv().await;
+        if v["id"] == json!(p2) {
+            assert_eq!(v["error"]["code"], -32602);
+            saw_p2_err = true;
+        } else if v["id"] == json!(p1) {
+            assert_eq!(v["result"]["stopReason"], "end_turn");
+            saw_p1_ok = true;
+        }
+        if saw_p1_ok && saw_p2_err {
+            break;
+        }
+    }
+    assert!(saw_p2_err, "expected concurrent prompt rejection");
+    assert!(saw_p1_ok, "first prompt didn't complete");
+    h.shutdown().await;
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn rejects_oversized_line() {
+    // Set a tiny max line and send something larger; agent must abort with an
+    // io error and not OOM.
+    let url = spawn_fake_llm(vec![]).await;
+    let bin = env!("CARGO_BIN_EXE_sprout-agent");
+    let mut cmd = tokio::process::Command::new(bin);
+    cmd.env("SPROUT_AGENT_PROVIDER", "openai")
+        .env("OPENAI_COMPAT_API_KEY", "test")
+        .env("OPENAI_COMPAT_MODEL", "fake-model")
+        .env("OPENAI_COMPAT_BASE_URL", &url)
+        .env("SPROUT_AGENT_MAX_LINE_BYTES", "256")
+        .stdin(Stdio::piped())
+        .stdout(Stdio::piped())
+        .stderr(Stdio::null())
+        .kill_on_drop(true);
+    let mut child = cmd.spawn().unwrap();
+    let mut stdin = child.stdin.take().unwrap();
+    // 1024-byte line — agent should reject and exit.
+    let big = "x".repeat(1024);
+    let _ = stdin.write_all(big.as_bytes()).await;
+    let _ = stdin.write_all(b"\n").await;
+    drop(stdin);
+    let _ = tokio::time::timeout(Duration::from_secs(5), child.wait())
+        .await
+        .expect("agent didn't exit after oversized line");
+}
diff --git a/crates/sprout-agent/tests/golden_transcripts.rs b/crates/sprout-agent/tests/golden_transcripts.rs
new file mode 100644
index 0000000000..44c1e2b81d
--- /dev/null
+++ b/crates/sprout-agent/tests/golden_transcripts.rs
@@ -0,0 +1,542 @@
+use std::collections::VecDeque;
+use std::process::Stdio;
+use std::sync::Arc;
+use std::time::Duration;
+
+use serde_json::{json, Value};
+use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
+use tokio::net::TcpListener;
+use tokio::sync::Mutex;
+
+struct Harness {
+    child: tokio::process::Child,
+    stdin: tokio::process::ChildStdin,
+    stdout: BufReader<tokio::process::ChildStdout>,
+    next_id: i64,
+}
+
+impl Harness {
+    async fn spawn(extra: &[(&str, &str)]) -> Self {
+        let bin = env!("CARGO_BIN_EXE_sprout-agent");
+        let mut cmd = tokio::process::Command::new(bin);
+        cmd.env("SPROUT_AGENT_PROVIDER", "openai")
+            .env("OPENAI_COMPAT_API_KEY", "test")
+            .env("OPENAI_COMPAT_MODEL", "fake-model")
+            .env("SPROUT_AGENT_LLM_TIMEOUT_SECS", "5")
+            .env("SPROUT_AGENT_TOOL_TIMEOUT_SECS", "5")
+            .env("SPROUT_AGENT_MAX_ROUNDS", "4")
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            .stderr(Stdio::null())
+            .kill_on_drop(true);
+        for (k, v) in extra {
+            cmd.env(k, v);
+        }
+        let mut child = cmd.spawn().expect("spawn sprout-agent");
+        let stdin = child.stdin.take().unwrap();
+        let stdout = BufReader::new(child.stdout.take().unwrap());
+        Self {
+            child,
+            stdin,
+            stdout,
+            next_id: 1,
+        }
+    }
+
+    async fn send(&mut self, method: &str, params: Value) -> i64 {
+        let id = self.next_id;
+        self.next_id += 1;
+        self.write_json(json!({
+            "jsonrpc": "2.0", "id": id, "method": method, "params": params
+        }))
+        .await;
+        id
+    }
+
+    async fn notify(&mut self, method: &str, params: Value) {
+        self.write_json(json!({
+            "jsonrpc": "2.0", "method": method, "params": params
+        }))
+        .await;
+    }
+
+    async fn write_json(&mut self, msg: Value) {
+        let mut s = serde_json::to_string(&msg).unwrap();
+        s.push('\n');
+        self.stdin.write_all(s.as_bytes()).await.unwrap();
+        self.stdin.flush().await.unwrap();
+    }
+
+    async fn write_raw(&mut self, raw: &[u8]) {
+        let _ = self.stdin.write_all(raw).await;
+        let _ = self.stdin.flush().await;
+    }
+
+    async fn recv(&mut self) -> Value {
+        let mut line = String::new();
+        let n = tokio::time::timeout(Duration::from_secs(10), self.stdout.read_line(&mut line))
+            .await
+            .expect("recv timeout")
+            .expect("read line");
+        assert!(n > 0, "agent EOF");
+        serde_json::from_str(&line).expect("non-JSON line")
+    }
+
+    async fn recv_for_id(&mut self, id: i64) -> Value {
+        loop {
+            let v = self.recv().await;
+            if v["id"] == json!(id) {
+                return v;
+            }
+        }
+    }
+
+    async fn recv_until<F: FnMut(&Value) -> bool>(&mut self, mut pred: F) -> Value {
+        loop {
+            let v = self.recv().await;
+            if pred(&v) {
+                return v;
+            }
+        }
+    }
+
+    async fn shutdown(mut self) {
+        drop(self.stdin);
+        let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await;
+        let _ = self.child.start_kill();
+    }
+}
+
+async fn spawn_fake_llm(responses: Vec<Value>) -> String {
+    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let url = format!("http://{}", listener.local_addr().unwrap());
+    let queue = Arc::new(Mutex::new(VecDeque::from(responses)));
+    tokio::spawn(async move {
+        loop {
+            let (mut sock, _) = match listener.accept().await {
+                Ok(p) => p,
+                Err(_) => return,
+            };
+            let queue = queue.clone();
+            tokio::spawn(async move {
+                let mut buf = Vec::new();
+                let mut tmp = [0u8; 4096];
+                while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
+                    match sock.read(&mut tmp).await {
+                        Ok(0) | Err(_) => return,
+                        Ok(n) => buf.extend_from_slice(&tmp[..n]),
+                    }
+                    if buf.len() > 1_000_000 {
+                        return;
+                    }
+                }
+                let body = queue
+                    .lock()
+                    .await
+                    .pop_front()
+                    .unwrap_or_else(|| json!({ "error": "no canned response" }));
+                let body_s = serde_json::to_string(&body).unwrap();
+                let resp = format!(
+                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
+                    body_s.len(),
+                    body_s,
+                );
+                let _ = sock.write_all(resp.as_bytes()).await;
+                let _ = sock.shutdown().await;
+            });
+        }
+    });
+    url
+}
+
+fn openai_text(content: &str) -> Value {
+    json!({
+        "id": "cc-1", "object": "chat.completion", "model": "fake-model",
+        "choices": [{
+            "index": 0,
+            "message": { "role": "assistant", "content": content },
+            "finish_reason": "stop",
+        }],
+    })
+}
+
+fn openai_tool_call(id: &str, name: &str, args: Value) -> Value {
+    json!({
+        "id": "cc-2", "object": "chat.completion", "model": "fake-model",
+        "choices": [{
+            "index": 0,
+            "message": {
+                "role": "assistant", "content": null,
+                "tool_calls": [{
+                    "id": id, "type": "function",
+                    "function": { "name": name, "arguments": args.to_string() },
+                }],
+            },
+            "finish_reason": "tool_calls",
+        }],
+    })
+}
+
+async fn handshake(h: &mut Harness) -> String {
+    let init_id = h
+        .send(
+            "initialize",
+            json!({ "protocolVersion": 1, "clientCapabilities": {} }),
+        )
+        .await;
+    let init = h.recv_for_id(init_id).await;
+    assert_eq!(init["result"]["protocolVersion"], 1);
+    assert_eq!(init["result"]["agentInfo"]["name"], "sprout-agent");
+    assert_eq!(
+        init["result"]["agentCapabilities"]["promptCapabilities"]["image"],
+        false
+    );
+
+    let new_id = h
+        .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] }))
+        .await;
+    let new = h.recv_for_id(new_id).await;
+    let sid = new["result"]["sessionId"].as_str().unwrap().to_owned();
+    assert!(sid.starts_with("ses_"));
+    sid
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_text_only_response() {
+    let url = spawn_fake_llm(vec![openai_text("hello back")]).await;
+    let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
+
+    let sid = handshake(&mut h).await;
+    let p = h
+        .send(
+            "session/prompt",
+            json!({
+                "sessionId": sid,
+                "prompt": [{ "type": "text", "text": "hi" }],
+            }),
+        )
+        .await;
+    let result = h.recv_for_id(p).await;
+    assert_eq!(result["result"]["stopReason"], "end_turn");
+    assert!(result.get("error").is_none());
+
+    h.shutdown().await;
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_full_tool_call_transcript() {
+    let url = spawn_fake_llm(vec![
+        openai_tool_call("call_xyz", "fake__do_thing", json!({ "foo": "bar" })),
+        openai_text("done"),
+    ])
+    .await;
+    let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
+
+    let sid = handshake(&mut h).await;
+    let p = h
+        .send(
+            "session/prompt",
+            json!({
+                "sessionId": sid,
+                "prompt": [{ "type": "text", "text": "use the tool" }],
+            }),
+        )
+        .await;
+
+    let failed = h
+        .recv_until(|v| {
+            v.get("method") == Some(&json!("session/update"))
+                && v["params"]["update"]["sessionUpdate"] == "tool_call_update"
+                && v["params"]["update"]["status"] == "failed"
+        })
+        .await;
+    assert_eq!(failed["params"]["sessionId"], sid);
+    assert_eq!(failed["params"]["update"]["toolCallId"], "call_xyz");
+    assert_eq!(
+        failed["params"]["update"]["rawOutput"]["error"],
+        "unknown tool: fake__do_thing"
+    );
+
+    let final_resp = h.recv_for_id(p).await;
+    assert_eq!(final_resp["result"]["stopReason"], "end_turn");
+
+    h.shutdown().await;
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_permission_denied_continues() {
+    let url = spawn_fake_llm(vec![openai_text("ok with no tool")]).await;
+    let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
+
+    let sid = handshake(&mut h).await;
+    let p = h
+        .send(
+            "session/prompt",
+            json!({
+                "sessionId": sid,
+                "prompt": [{ "type": "text", "text": "hi" }],
+            }),
+        )
+        .await;
+    let final_resp = h.recv_for_id(p).await;
+    assert_eq!(final_resp["result"]["stopReason"], "end_turn");
+
+    h.shutdown().await;
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_initialize_version_check() {
+    let url = spawn_fake_llm(vec![]).await;
+    let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
+
+    let id = h
+        .send(
+            "initialize",
+            json!({ "protocolVersion": 99, "clientCapabilities": {} }),
+        )
+        .await;
+    let resp = h.recv_for_id(id).await;
+    assert_eq!(resp["result"]["protocolVersion"], 1);
+
+    let id2 = h
+        .send(
+            "initialize",
+            json!({ "protocolVersion": 1, "clientCapabilities": {} }),
+        )
+        .await;
+    let ok = h.recv_for_id(id2).await;
+    assert_eq!(ok["result"]["protocolVersion"], 1);
+
+    h.shutdown().await;
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_session_new_rejects_relative_cwd() {
+    let url = spawn_fake_llm(vec![]).await;
+    let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
+
+    let _ = h
+        .send(
+            "initialize",
+            json!({ "protocolVersion": 1, "clientCapabilities": {} }),
+        )
+        .await;
+    let _ = h.recv().await;
+
+    let id = h
+        .send(
+            "session/new",
+            json!({ "cwd": "relative/path", "mcpServers": [] }),
+        )
+        .await;
+    let resp = h.recv_for_id(id).await;
+    assert_eq!(resp["error"]["code"], -32602);
+    assert!(resp["error"]["message"]
+        .as_str()
+        .unwrap()
+        .contains("cwd must be an absolute path"));
+
+    let id_empty = h
+        .send("session/new", json!({ "cwd": "", "mcpServers": [] }))
+        .await;
+    let resp = h.recv_for_id(id_empty).await;
+    assert_eq!(resp["error"]["code"], -32602);
+
+    h.shutdown().await;
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_malformed_json_rpc() {
+    let url = spawn_fake_llm(vec![]).await;
+    let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
+
+    h.write_raw(b"this is not json\n").await;
+    let v = h.recv().await;
+    assert_eq!(v["error"]["code"], -32700);
+    assert_eq!(v["id"], Value::Null);
+
+    h.write_json(json!({ "jsonrpc": "1.0", "method": "initialize", "id": 1 }))
+        .await;
+    let v = h.recv().await;
+    assert_eq!(v["error"]["code"], -32600);
+
+    h.write_json(json!({ "jsonrpc": "2.0" })).await;
+    let v = h.recv().await;
+    assert_eq!(v["error"]["code"], -32600);
+
+    let init_id = h
+        .send(
+            "initialize",
+            json!({ "protocolVersion": 1, "clientCapabilities": {} }),
+        )
+        .await;
+    let ok = h.recv_for_id(init_id).await;
+    assert_eq!(ok["result"]["protocolVersion"], 1);
+
+    let bad_id = h.send("nonsense/method", json!({})).await;
+    let v = h.recv_for_id(bad_id).await;
+    assert_eq!(v["error"]["code"], -32601);
+
+    h.shutdown().await;
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_unsupported_content_block() {
+    let url = spawn_fake_llm(vec![openai_text("ok")]).await;
+    let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
+
+    let sid = handshake(&mut h).await;
+    let p = h
+        .send(
+            "session/prompt",
+            json!({
+                "sessionId": sid,
+                "prompt": [{ "type": "image", "data": "..." }],
+            }),
+        )
+        .await;
+    let resp = h.recv_for_id(p).await;
+    assert_eq!(resp["error"]["code"], -32602);
+    assert!(resp["error"]["message"]
+        .as_str()
+        .unwrap()
+        .contains("unsupported content block"));
+
+    h.shutdown().await;
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_concurrent_prompt_rejected() {
+    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let url = format!("http://{}", listener.local_addr().unwrap());
+    tokio::spawn(async move {
+        let (mut sock, _) = listener.accept().await.unwrap();
+        let mut buf = Vec::new();
+        let mut tmp = [0u8; 4096];
+        while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
+            let n = sock.read(&mut tmp).await.unwrap_or(0);
+            if n == 0 {
+                return;
+            }
+            buf.extend_from_slice(&tmp[..n]);
+        }
+        tokio::time::sleep(Duration::from_millis(500)).await;
+        let body = openai_text("done").to_string();
+        let resp = format!(
+            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
+            body.len(),
+            body
+        );
+        let _ = sock.write_all(resp.as_bytes()).await;
+        let _ = sock.shutdown().await;
+    });
+
+    let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
+    let sid = handshake(&mut h).await;
+
+    let p1 = h
+        .send(
+            "session/prompt",
+            json!({ "sessionId": sid, "prompt": [{"type":"text","text":"go"}] }),
+        )
+        .await;
+    tokio::time::sleep(Duration::from_millis(50)).await;
+    let p2 = h
+        .send(
+            "session/prompt",
+            json!({ "sessionId": sid, "prompt": [{"type":"text","text":"again"}] }),
+        )
+        .await;
+
+    let mut p1_ok = false;
+    let mut p2_err = false;
+    for _ in 0..10 {
+        let v = h.recv().await;
+        if v["id"] == json!(p1) {
+            assert_eq!(v["result"]["stopReason"], "end_turn");
+            p1_ok = true;
+        } else if v["id"] == json!(p2) {
+            assert_eq!(v["error"]["code"], -32602);
+            p2_err = true;
+        }
+        if p1_ok && p2_err {
+            break;
+        }
+    }
+    assert!(p1_ok && p2_err, "expected p1=ok, p2=busy");
+    h.shutdown().await;
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_oversized_line_kills_agent() {
+    let url = spawn_fake_llm(vec![]).await;
+    let bin = env!("CARGO_BIN_EXE_sprout-agent");
+    let mut cmd = tokio::process::Command::new(bin);
+    cmd.env("SPROUT_AGENT_PROVIDER", "openai")
+        .env("OPENAI_COMPAT_API_KEY", "test")
+        .env("OPENAI_COMPAT_MODEL", "fake-model")
+        .env("OPENAI_COMPAT_BASE_URL", &url)
+        .env("SPROUT_AGENT_MAX_LINE_BYTES", "256")
+        .stdin(Stdio::piped())
+        .stdout(Stdio::piped())
+        .stderr(Stdio::null())
+        .kill_on_drop(true);
+    let mut child = cmd.spawn().unwrap();
+    let mut stdin = child.stdin.take().unwrap();
+    let big = "x".repeat(1024);
+    let _ = stdin.write_all(big.as_bytes()).await;
+    let _ = stdin.write_all(b"\n").await;
+    drop(stdin);
+    let _ = tokio::time::timeout(Duration::from_secs(5), child.wait())
+        .await
+        .expect("agent did not exit on oversized line");
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_cancel_notification_no_reply() {
+    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let url = format!("http://{}", listener.local_addr().unwrap());
+    tokio::spawn(async move {
+        let (mut sock, _) = listener.accept().await.unwrap();
+        let mut buf = Vec::new();
+        let mut tmp = [0u8; 4096];
+        while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
+            let n = sock.read(&mut tmp).await.unwrap_or(0);
+            if n == 0 {
+                return;
+            }
+            buf.extend_from_slice(&tmp[..n]);
+        }
+        tokio::time::sleep(Duration::from_millis(800)).await;
+        let body = openai_text("done").to_string();
+        let resp = format!(
+            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
+            body.len(),
+            body
+        );
+        let _ = sock.write_all(resp.as_bytes()).await;
+        let _ = sock.shutdown().await;
+    });
+
+    let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
+    let sid = handshake(&mut h).await;
+
+    let p = h
+        .send(
+            "session/prompt",
+            json!({ "sessionId": sid, "prompt": [{"type":"text","text":"go"}] }),
+        )
+        .await;
+    tokio::time::sleep(Duration::from_millis(50)).await;
+    h.notify("session/cancel", json!({ "sessionId": sid }))
+        .await;
+
+    let final_resp = h.recv_for_id(p).await;
+    let stop = final_resp["result"]["stopReason"].as_str().unwrap_or("");
+    assert!(
+        stop == "cancelled" || stop == "end_turn",
+        "unexpected stopReason {stop}"
+    );
+
+    h.shutdown().await;
+}
diff --git a/crates/sprout-agent/tests/regressions.rs b/crates/sprout-agent/tests/regressions.rs
new file mode 100644
index 0000000000..1f2afcfd57
--- /dev/null
+++ b/crates/sprout-agent/tests/regressions.rs
@@ -0,0 +1,1420 @@
+//! Regression tests for round 4-6 hardening:
+//!   - assistant text preserved in history
+//!   - MCP init timeout (with explicit child kill)
+//!   - tool metadata caps (description bytes, count)
+//!   - cancellation leaves history valid for the next prompt
+//!   - empty-content assistant turn doesn't poison OpenAI history
+
+use std::collections::VecDeque;
+use std::process::Stdio;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use serde_json::{json, Value};
+use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
+use tokio::net::TcpListener;
+use tokio::sync::Mutex;
+
+// ─── Fake LLM that captures requests so we can inspect history ──────────────
+
+struct CapturingLlm {
+    url: String,
+    captured: Arc<Mutex<Vec<Value>>>,
+}
+
+async fn spawn_capturing_llm(responses: Vec<Value>) -> CapturingLlm {
+    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let url = format!("http://{}", listener.local_addr().unwrap());
+    let queue = Arc::new(Mutex::new(VecDeque::from(responses)));
+    let captured: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
+    let cap2 = captured.clone();
+    tokio::spawn(async move {
+        loop {
+            let (mut sock, _) = match listener.accept().await {
+                Ok(p) => p,
+                Err(_) => return,
+            };
+            let queue = queue.clone();
+            let captured = cap2.clone();
+            tokio::spawn(async move {
+                let mut buf = Vec::new();
+                let mut tmp = [0u8; 8192];
+                // Read until headers complete.
+                while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
+                    match sock.read(&mut tmp).await {
+                        Ok(0) | Err(_) => return,
+                        Ok(n) => buf.extend_from_slice(&tmp[..n]),
+                    }
+                    if buf.len() > 4_000_000 {
+                        return;
+                    }
+                }
+                // Parse Content-Length and read body.
+                let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
+                let headers = &buf[..header_end];
+                let mut body_len = 0usize;
+                for line in headers.split(|b| *b == b'\n') {
+                    let line = std::str::from_utf8(line).unwrap_or("");
+                    if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-length:") {
+                        body_len = rest.trim().trim_end_matches('\r').parse().unwrap_or(0);
+                    }
+                }
+                while buf.len() < header_end + body_len {
+                    match sock.read(&mut tmp).await {
+                        Ok(0) | Err(_) => return,
+                        Ok(n) => buf.extend_from_slice(&tmp[..n]),
+                    }
+                }
+                if let Ok(req) = serde_json::from_slice::<Value>(&buf[header_end..]) {
+                    captured.lock().await.push(req);
+                }
+                let body = queue
+                    .lock()
+                    .await
+                    .pop_front()
+                    .unwrap_or_else(|| json!({ "error": "no canned response" }));
+                let body_s = serde_json::to_string(&body).unwrap();
+                let resp = format!(
+                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
+                    body_s.len(), body_s,
+                );
+                let _ = sock.write_all(resp.as_bytes()).await;
+                let _ = sock.shutdown().await;
+            });
+        }
+    });
+    CapturingLlm { url, captured }
+}
+
+// ─── Harness (minimal copy — keeping per-test independence) ─────────────────
+
+struct Harness {
+    child: tokio::process::Child,
+    stdin: tokio::process::ChildStdin,
+    stdout: BufReader<tokio::process::ChildStdout>,
+    next_id: i64,
+}
+
+impl Harness {
+    async fn spawn_with_env(base_url: &str, extra: &[(&str, &str)]) -> Self {
+        let bin = env!("CARGO_BIN_EXE_sprout-agent");
+        let mut cmd = tokio::process::Command::new(bin);
+        cmd.env("SPROUT_AGENT_PROVIDER", "openai")
+            .env("OPENAI_COMPAT_API_KEY", "test")
+            .env("OPENAI_COMPAT_MODEL", "fake-model")
+            .env("OPENAI_COMPAT_BASE_URL", base_url)
+            .env("SPROUT_AGENT_LLM_TIMEOUT_SECS", "5")
+            .env("SPROUT_AGENT_TOOL_TIMEOUT_SECS", "5")
+            .env("SPROUT_AGENT_MAX_ROUNDS", "8")
+            .env("SPROUT_AGENT_MCP_INIT_TIMEOUT_SECS", "2");
+        for (k, v) in extra {
+            cmd.env(k, v);
+        }
+        cmd.stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            .stderr(Stdio::inherit())
+            .kill_on_drop(true);
+        let mut child = cmd.spawn().expect("spawn sprout-agent");
+        let stdin = child.stdin.take().unwrap();
+        let stdout = BufReader::new(child.stdout.take().unwrap());
+        Self {
+            child,
+            stdin,
+            stdout,
+            next_id: 1,
+        }
+    }
+
+    async fn spawn(base_url: &str) -> Self {
+        Self::spawn_with_env(base_url, &[]).await
+    }
+
+    async fn send(&mut self, method: &str, params: Value) -> i64 {
+        let id = self.next_id;
+        self.next_id += 1;
+        self.write(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }))
+            .await;
+        id
+    }
+
+    async fn notify(&mut self, method: &str, params: Value) {
+        self.write(json!({ "jsonrpc": "2.0", "method": method, "params": params }))
+            .await;
+    }
+
+    async fn write(&mut self, msg: Value) {
+        let mut s = serde_json::to_string(&msg).unwrap();
+        s.push('\n');
+        self.stdin.write_all(s.as_bytes()).await.unwrap();
+        self.stdin.flush().await.unwrap();
+    }
+
+    async fn recv(&mut self) -> Value {
+        let mut line = String::new();
+        let n = tokio::time::timeout(Duration::from_secs(15), self.stdout.read_line(&mut line))
+            .await
+            .expect("recv timeout")
+            .expect("read line");
+        assert!(n > 0, "agent EOF");
+        serde_json::from_str(&line).expect("non-JSON line")
+    }
+
+    async fn recv_until<F: FnMut(&Value) -> bool>(&mut self, mut pred: F) -> Value {
+        loop {
+            let v = self.recv().await;
+            if pred(&v) {
+                return v;
+            }
+        }
+    }
+
+    async fn shutdown(mut self) {
+        drop(self.stdin);
+        let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await;
+        let _ = self.child.start_kill();
+    }
+}
+
+fn openai_text(content: &str) -> Value {
+    json!({
+        "id": "cc-1", "object": "chat.completion", "model": "fake-model",
+        "choices": [{
+            "index": 0,
+            "message": { "role": "assistant", "content": content },
+            "finish_reason": "stop",
+        }],
+    })
+}
+
+fn openai_tool_call(id: &str, name: &str, args: Value) -> Value {
+    json!({
+        "id": "cc-2", "object": "chat.completion", "model": "fake-model",
+        "choices": [{
+            "index": 0,
+            "message": {
+                "role": "assistant", "content": null,
+                "tool_calls": [{
+                    "id": id, "type": "function",
+                    "function": { "name": name, "arguments": args.to_string() },
+                }],
+            },
+            "finish_reason": "tool_calls",
+        }],
+    })
+}
+
+async fn init_session(h: &mut Harness, mcp_servers: Value) -> String {
+    h.send(
+        "initialize",
+        json!({"protocolVersion":1,"clientCapabilities":{}}),
+    )
+    .await;
+    let _ = h.recv().await;
+    h.send(
+        "session/new",
+        json!({"cwd":"/tmp","mcpServers": mcp_servers}),
+    )
+    .await;
+    let r = h
+        .recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
+        .await;
+    r["result"]["sessionId"]
+        .as_str()
+        .expect("sessionId")
+        .to_owned()
+}
+
+// ─── Tests ──────────────────────────────────────────────────────────────────
+
+/// After a text-only assistant response, the next prompt's request must
+/// include that assistant text in `messages` history. Round 4 fix.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn assistant_text_preserved_across_prompts() {
+    let llm = spawn_capturing_llm(vec![openai_text("hello world"), openai_text("done")]).await;
+    let mut h = Harness::spawn(&llm.url).await;
+    let sid = init_session(&mut h, json!([])).await;
+
+    // Prompt 1.
+    let p1 = h
+        .send(
+            "session/prompt",
+            json!({"sessionId": sid, "prompt": [{"type":"text","text":"first"}]}),
+        )
+        .await;
+    let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
+
+    // Prompt 2 — should carry assistant text from prompt 1.
+    let p2 = h
+        .send(
+            "session/prompt",
+            json!({"sessionId": sid, "prompt": [{"type":"text","text":"second"}]}),
+        )
+        .await;
+    let _ = h.recv_until(|v| v["id"] == json!(p2)).await;
+
+    let captured = llm.captured.lock().await;
+    assert_eq!(captured.len(), 2, "expected 2 LLM requests");
+    let msgs = captured[1]["messages"].as_array().unwrap();
+    let assistants: Vec<&Value> = msgs.iter().filter(|m| m["role"] == "assistant").collect();
+    assert!(
+        assistants.iter().any(|m| m["content"] == "hello world"),
+        "assistant text was dropped: messages={msgs:?}"
+    );
+    h.shutdown().await;
+}
+
+/// MCP init that hangs forever must time out within ~2s, surface an error,
+/// and the child process must be killed (not lingering).
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn mcp_init_timeout_kills_child() {
+    let llm = spawn_capturing_llm(vec![]).await;
+    let mut h = Harness::spawn(&llm.url).await;
+
+    let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
+    h.send(
+        "initialize",
+        json!({"protocolVersion":1,"clientCapabilities":{}}),
+    )
+    .await;
+    let _ = h.recv().await;
+
+    let start = Instant::now();
+    h.send(
+        "session/new",
+        json!({
+            "cwd": "/tmp",
+            "mcpServers": [{
+                "name": "stuck",
+                "command": fake_mcp,
+                "args": [],
+                "env": [{ "name": "FAKE_MCP_HANG_INIT", "value": "1" }],
+            }],
+        }),
+    )
+    .await;
+    let r = h
+        .recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
+        .await;
+    let elapsed = start.elapsed();
+
+    assert!(r.get("error").is_some(), "expected error, got {r}");
+    let msg = r["error"]["message"].as_str().unwrap_or("");
+    assert!(msg.contains("timeout"), "error not a timeout: {msg}");
+    // 2s timeout + small slack. Generous to cover slow CI.
+    assert!(
+        elapsed < Duration::from_secs(8),
+        "timeout took too long: {elapsed:?}"
+    );
+    h.shutdown().await;
+}
+
+/// A real MCP server that returns 200 tools with 100KB descriptions must
+/// be capped: tool count ≤ MAX_TOOLS_PER_SESSION (128) — we expect spawn_all
+/// to either reject (too many) OR truncate. We assert the spawn succeeds with
+/// a bounded count, and that descriptions sent to the LLM are ≤ 1024 bytes.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn tool_metadata_caps_enforced() {
+    let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
+    let mut h = Harness::spawn(&llm.url).await;
+
+    let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
+    h.send(
+        "initialize",
+        json!({"protocolVersion":1,"clientCapabilities":{}}),
+    )
+    .await;
+    let _ = h.recv().await;
+    h.send(
+        "session/new",
+        json!({
+            "cwd": "/tmp",
+            "mcpServers": [{
+                "name": "many",
+                "command": fake_mcp,
+                "args": [],
+                "env": [
+                    { "name": "FAKE_MCP_TOOL_COUNT", "value": "200" },
+                    { "name": "FAKE_MCP_HUGE_DESC", "value": "1" },
+                ],
+            }],
+        }),
+    )
+    .await;
+    let r = h
+        .recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
+        .await;
+
+    // Either spawn rejects (200 > 128 cap) — that's acceptable hardening —
+    // OR it accepts and we verify the LLM request stays bounded.
+    if r.get("error").is_some() {
+        let msg = r["error"]["message"].as_str().unwrap_or("");
+        assert!(msg.contains("too many"), "unexpected error: {msg}");
+        h.shutdown().await;
+        return;
+    }
+
+    let sid = r["result"]["sessionId"].as_str().unwrap().to_owned();
+    let p = h
+        .send(
+            "session/prompt",
+            json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
+        )
+        .await;
+    let _ = h.recv_until(|v| v["id"] == json!(p)).await;
+
+    let captured = llm.captured.lock().await;
+    assert!(!captured.is_empty(), "no LLM request captured");
+    let tools = captured[0]["tools"].as_array().unwrap();
+    assert!(tools.len() <= 128, "tool count not capped: {}", tools.len());
+    for t in tools {
+        let desc = t["function"]["description"].as_str().unwrap_or("");
+        assert!(
+            desc.len() <= 1024,
+            "description not capped: {} bytes",
+            desc.len()
+        );
+    }
+    h.shutdown().await;
+}
+
+/// Cap on MCP server count: 17 servers must be rejected.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn mcp_server_count_cap() {
+    let llm = spawn_capturing_llm(vec![]).await;
+    let mut h = Harness::spawn(&llm.url).await;
+    h.send(
+        "initialize",
+        json!({"protocolVersion":1,"clientCapabilities":{}}),
+    )
+    .await;
+    let _ = h.recv().await;
+
+    let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
+    let servers: Vec<Value> = (0..17)
+        .map(|i| {
+            json!({
+                "name": format!("s{i}"),
+                "command": fake_mcp,
+                "args": [],
+                "env": [],
+            })
+        })
+        .collect();
+    h.send("session/new", json!({"cwd":"/tmp","mcpServers": servers}))
+        .await;
+    let r = h
+        .recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
+        .await;
+    assert!(r.get("error").is_some(), "expected error for 17 servers");
+    let msg = r["error"]["message"].as_str().unwrap_or("");
+    assert!(msg.contains("too many"), "wrong error: {msg}");
+    h.shutdown().await;
+}
+
+/// After cancelling mid-tool-loop, the next prompt must succeed without
+/// the LLM seeing a malformed history (assistant tool_use with no
+/// matching tool_result). Round 5 fix.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn cancel_leaves_history_valid_for_next_prompt() {
+    // Round 1: tool call (unknown — fails fast, no permission flow).
+    // Round 2: text "ok".
+    // After cancel, prompt 2 returns text immediately.
+    let llm = spawn_capturing_llm(vec![
+        openai_tool_call("tc1", "fake__t", json!({})),
+        openai_text("after-cancel"),
+        openai_text("p2-done"),
+    ])
+    .await;
+    let mut h = Harness::spawn(&llm.url).await;
+    let sid = init_session(&mut h, json!([])).await;
+
+    let p1 = h
+        .send(
+            "session/prompt",
+            json!({"sessionId": sid, "prompt": [{"type":"text","text":"first"}]}),
+        )
+        .await;
+    // Cancel right away; the agent races between cancellation and the LLM
+    // round trip — either way history must remain valid.
+    h.notify("session/cancel", json!({"sessionId": sid})).await;
+    let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
+
+    // Prompt 2 — must NOT error from a malformed history.
+    let p2 = h
+        .send(
+            "session/prompt",
+            json!({"sessionId": sid, "prompt": [{"type":"text","text":"second"}]}),
+        )
+        .await;
+    let r = h.recv_until(|v| v["id"] == json!(p2)).await;
+    assert!(r.get("result").is_some(), "p2 errored: {r}");
+    h.shutdown().await;
+}
+
+/// Empty assistant content + no tool_calls must serialize as "" (not null)
+/// for OpenAI, so subsequent prompts don't get rejected. Round 7 fix 6.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn empty_assistant_serializes_as_empty_string() {
+    // First call returns content="" finish_reason=stop — agent records an
+    // empty assistant turn. Second call's request body is what we inspect.
+    let llm = spawn_capturing_llm(vec![openai_text(""), openai_text("done")]).await;
+    let mut h = Harness::spawn(&llm.url).await;
+    let sid = init_session(&mut h, json!([])).await;
+
+    let p1 = h
+        .send(
+            "session/prompt",
+            json!({"sessionId": sid, "prompt": [{"type":"text","text":"a"}]}),
+        )
+        .await;
+    let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
+    let p2 = h
+        .send(
+            "session/prompt",
+            json!({"sessionId": sid, "prompt": [{"type":"text","text":"b"}]}),
+        )
+        .await;
+    let _ = h.recv_until(|v| v["id"] == json!(p2)).await;
+
+    let captured = llm.captured.lock().await;
+    let msgs = captured[1]["messages"].as_array().unwrap();
+    let empty_assistant = msgs
+        .iter()
+        .find(|m| m["role"] == "assistant" && m.get("tool_calls").is_none())
+        .expect("no plain assistant turn");
+    // Must be empty string, NOT null.
+    assert_eq!(
+        empty_assistant["content"],
+        json!(""),
+        "expected empty string content, got {empty_assistant}"
+    );
+    h.shutdown().await;
+}
+
+fn openai_n_tool_calls(n: usize) -> Value {
+    let calls: Vec<Value> = (0..n)
+        .map(|i| {
+            json!({
+                "id": format!("c{i}"),
+                "type": "function",
+                "function": { "name": "many__tool_0", "arguments": "{}" },
+            })
+        })
+        .collect();
+    json!({
+        "id": "cc-n", "object": "chat.completion", "model": "fake-model",
+        "choices": [{
+            "index": 0,
+            "message": { "role": "assistant", "content": null, "tool_calls": calls },
+            "finish_reason": "tool_calls",
+        }],
+    })
+}
+
+// ─── New round-8 regression tests ──────────────────────────────────────────
+
+/// History budget evicts old turns: after many prompts, the LLM request
+/// body stays below a sane bound. Round 7 fix; round 8 test.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn history_budget_evicts_old_turns() {
+    // Budget = 1 MB (MIN allowed by config). Each prompt is ~200 KB, so
+    // 12 prompts × 200 KB = ~2.4 MB blows the cap and forces eviction.
+    // We expect the captured request body to stay under 3× the cap.
+    const BUDGET: usize = 1024 * 1024; // 1 MB — must be >= MAX_PROMPT_BYTES
+    const PROMPT_BYTES: usize = 200 * 1024; // 200 KB per turn
+    let responses: Vec<Value> = (0..12).map(|_| openai_text(&"y".repeat(200))).collect();
+    let llm = spawn_capturing_llm(responses).await;
+    let mut h = Harness::spawn_with_env(
+        &llm.url,
+        &[
+            ("SPROUT_AGENT_MAX_HISTORY_BYTES", &BUDGET.to_string()),
+            ("SPROUT_AGENT_MAX_HANDOFFS", "0"), // exercise truncation, not handoff
+        ],
+    )
+    .await;
+    let sid = init_session(&mut h, json!([])).await;
+
+    for i in 0..12 {
+        let user = "x".repeat(PROMPT_BYTES);
+        let p = h
+            .send(
+                "session/prompt",
+                json!({"sessionId": sid, "prompt": [{"type":"text","text": format!("{i}:{user}")}]}),
+            )
+            .await;
+        let _ = h.recv_until(|v| v["id"] == json!(p)).await;
+    }
+
+    let captured = llm.captured.lock().await;
+    assert_eq!(captured.len(), 12);
+    // The last request must show eviction: body well under unbounded 12 × 200 KB = 2.4 MB.
+    let last = &captured[captured.len() - 1];
+    let body_bytes = serde_json::to_vec(last).unwrap().len();
+    assert!(
+        body_bytes < BUDGET * 3,
+        "history not evicted: request body is {body_bytes} bytes"
+    );
+    let msgs = last["messages"].as_array().unwrap();
+    // We must NEVER drop the latest user prompt.
+    assert!(
+        msgs.iter()
+            .any(|m| m["role"] == "user" && m["content"].as_str().unwrap_or("").starts_with("11:")),
+        "newest user turn missing"
+    );
+    h.shutdown().await;
+}
+
+/// Per-turn tool-call cap: an LLM that returns 100 tool_calls in one
+/// response must only have 64 (MAX_TOOL_CALLS_PER_TURN) executed.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn per_turn_tool_call_cap_enforced() {
+    let llm = spawn_capturing_llm(vec![openai_n_tool_calls(100), openai_text("done")]).await;
+    let mut h = Harness::spawn(&llm.url).await;
+
+    let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
+    h.send(
+        "initialize",
+        json!({"protocolVersion":1,"clientCapabilities":{}}),
+    )
+    .await;
+    let _ = h.recv().await;
+    h.send(
+        "session/new",
+        json!({
+            "cwd": "/tmp",
+            "mcpServers": [{
+                "name": "many",
+                "command": fake_mcp,
+                "args": [],
+                "env": [{ "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }],
+            }],
+        }),
+    )
+    .await;
+    let r = h
+        .recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
+        .await;
+    let sid = r["result"]["sessionId"].as_str().unwrap().to_owned();
+
+    let p = h
+        .send(
+            "session/prompt",
+            json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
+        )
+        .await;
+
+    // Count distinct tool_call (pending) notifications until final response.
+    let mut tool_call_ids = std::collections::HashSet::new();
+    loop {
+        let v = h.recv().await;
+        if v.get("method") == Some(&json!("session/request_permission")) {
+            let id = v["id"].clone();
+            h.write(json!({
+                "jsonrpc": "2.0",
+                "id": id,
+                "result": { "outcome": { "outcome": "selected", "optionId": "allow" } },
+            }))
+            .await;
+            continue;
+        }
+        if v.get("method") == Some(&json!("session/update"))
+            && v["params"]["update"]["sessionUpdate"] == "tool_call"
+        {
+            if let Some(id) = v["params"]["update"]["toolCallId"].as_str() {
+                tool_call_ids.insert(id.to_owned());
+            }
+            continue;
+        }
+        if v["id"] == json!(p) {
+            break;
+        }
+    }
+    // MAX_TOOL_CALLS_PER_TURN = 64.
+    assert_eq!(
+        tool_call_ids.len(),
+        64,
+        "expected 64 tool_calls, got {}",
+        tool_call_ids.len()
+    );
+    h.shutdown().await;
+}
+
+/// Description clamping: a 5000-byte description from MCP must be
+/// truncated to ≤ 1024 bytes in the LLM request.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn description_clamping_enforced() {
+    let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
+    let mut h = Harness::spawn(&llm.url).await;
+
+    let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
+    h.send(
+        "initialize",
+        json!({"protocolVersion":1,"clientCapabilities":{}}),
+    )
+    .await;
+    let _ = h.recv().await;
+    h.send(
+        "session/new",
+        json!({
+            "cwd": "/tmp",
+            "mcpServers": [{
+                "name": "big",
+                "command": fake_mcp,
+                "args": [],
+                "env": [
+                    { "name": "FAKE_MCP_TOOL_COUNT", "value": "1" },
+                    { "name": "FAKE_MCP_DESC_SIZE", "value": "5000" },
+                ],
+            }],
+        }),
+    )
+    .await;
+    let r = h
+        .recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
+        .await;
+    let sid = r["result"]["sessionId"].as_str().unwrap().to_owned();
+
+    let p = h
+        .send(
+            "session/prompt",
+            json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
+        )
+        .await;
+    let _ = h.recv_until(|v| v["id"] == json!(p)).await;
+
+    let captured = llm.captured.lock().await;
+    let tools = captured[0]["tools"].as_array().unwrap();
+    assert_eq!(tools.len(), 1);
+    let desc = tools[0]["function"]["description"].as_str().unwrap_or("");
+    assert!(
+        desc.len() <= 1024,
+        "description not clamped: {} bytes (expected ≤ 1024)",
+        desc.len()
+    );
+    // Sanity: the original was 5000 bytes, so we did clamp something.
+    assert!(
+        desc.len() < 5000,
+        "description not actually truncated: {} bytes",
+        desc.len()
+    );
+    h.shutdown().await;
+}
+
+// ─── Hook system regression tests ──────────────────────────────────────────
+
+/// Helper: spawn a session with a fake MCP server exposing one regular tool
+/// plus an optional `_Stop` hook controlled by env vars.
+async fn init_session_with_fake_mcp(h: &mut Harness, extra_mcp_env: &[(&str, &str)]) -> String {
+    let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
+    let env: Vec<Value> = extra_mcp_env
+        .iter()
+        .map(|(k, v)| json!({ "name": k, "value": v }))
+        .collect();
+    h.send(
+        "initialize",
+        json!({"protocolVersion":1,"clientCapabilities":{}}),
+    )
+    .await;
+    let _ = h.recv().await;
+    h.send(
+        "session/new",
+        json!({
+            "cwd": "/tmp",
+            "mcpServers": [{
+                "name": "fake",
+                "command": fake_mcp,
+                "args": [],
+                "env": env,
+            }],
+        }),
+    )
+    .await;
+    let r = h
+        .recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
+        .await;
+    r["result"]["sessionId"]
+        .as_str()
+        .expect("sessionId")
+        .to_owned()
+}
+
+/// `_Stop` hook objects on the first end_turn → agent must NOT stop.
+/// The hook returns an objection only on its first invocation; on the
+/// second end_turn (after a tool round), the hook stays silent so the
+/// agent ends cleanly. Verifies that the gate rerolls the LLM at least
+/// once, and that the objection appears in history as a tool-role
+/// message with the JSON-encoded source attribution.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn hook_stop_blocks_premature_end() {
+    // LLM sequence:
+    //   1. text "premature" (triggers _Stop objection — call #1)
+    //   2. tool_call to fake__tool_0 (regular tool, resets latch)
+    //   3. text "really done" (hook returns empty on call #2 → end)
+    let llm = spawn_capturing_llm(vec![
+        openai_text("premature"),
+        openai_tool_call("tc1", "fake__tool_0", json!({})),
+        openai_text("really done"),
+    ])
+    .await;
+    // stop_max_rejections=10 so the budget never trips. The hook itself
+    // stays silent on its second call (FAKE_MCP_STOP_COUNT=1) so the
+    // second end_turn is accepted by the agent — this exercises the
+    // genuine "objected then later cleared" path, not a budget cap.
+    let mut h = Harness::spawn_with_env(
+        &llm.url,
+        &[
+            ("MCP_HOOK_SERVERS", "fake"),
+            ("SPROUT_AGENT_STOP_MAX_REJECTIONS", "10"),
+        ],
+    )
+    .await;
+    let sid = init_session_with_fake_mcp(
+        &mut h,
+        &[
+            ("FAKE_MCP_TOOL_COUNT", "1"),
+            ("FAKE_MCP_STOP_HOOK", "1"),
+            ("FAKE_MCP_STOP_TEXT", "you have open work"),
+            // Objection text returned for the first STOP_COUNT calls;
+            // empty string thereafter.
+            ("FAKE_MCP_STOP_COUNT", "1"),
+        ],
+    )
+    .await;
+
+    let p = h
+        .send(
+            "session/prompt",
+            json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
+        )
+        .await;
+    let r = h.recv_until(|v| v["id"] == json!(p)).await;
+    assert!(r.get("result").is_some(), "errored: {r}");
+    assert_eq!(r["result"]["stopReason"], "end_turn");
+
+    // Agent must have called LLM ≥2 times (initial end_turn was rejected,
+    // forcing another LLM round). We expect exactly 3 here: text → tool → text.
+    let captured = llm.captured.lock().await;
+    assert!(
+        captured.len() >= 2,
+        "agent did not loop after objection: {} LLM calls",
+        captured.len()
+    );
+
+    // Round 2's request must carry the objection as a tool-role message
+    // (synthetic tool result), not a user/assistant message. Content is
+    // a JSON object with hook/server/text fields — never escapable.
+    let msgs = captured[1]["messages"].as_array().unwrap();
+    let objection_present = msgs.iter().any(|m| {
+        if m["role"] != "tool" {
+            return false;
+        }
+        let content = m["content"].as_str().unwrap_or("");
+        let parsed: Value = match serde_json::from_str(content) {
+            Ok(v) => v,
+            Err(_) => return false,
+        };
+        parsed["hook"] == "_Stop"
+            && parsed["server"] == "fake"
+            && parsed["text"]
+                .as_str()
+                .unwrap_or("")
+                .contains("you have open work")
+    });
+    assert!(
+        objection_present,
+        "objection (role=tool, JSON-encoded) missing from messages: {msgs:?}"
+    );
+    h.shutdown().await;
+}
+
+/// After `stop_max_rejections` objections, the agent honors end_turn
+/// even if `_Stop` would still object. Set max=1 so it trips quickly.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn hook_stop_budget_exhausted() {
+    // LLM sequence:
+    //   1. text → triggers _Stop objection (rejections: 0→1)
+    //   2. tool_call (resets last_was_end_turn)
+    //   3. text → gate sees rejections>=max, returns end_turn (no _Stop call)
+    let llm = spawn_capturing_llm(vec![
+        openai_text("first"),
+        openai_tool_call("tc1", "fake__tool_0", json!({})),
+        openai_text("second"),
+    ])
+    .await;
+    let mut h = Harness::spawn_with_env(
+        &llm.url,
+        &[
+            ("MCP_HOOK_SERVERS", "fake"),
+            ("SPROUT_AGENT_STOP_MAX_REJECTIONS", "1"),
+        ],
+    )
+    .await;
+    let sid = init_session_with_fake_mcp(
+        &mut h,
+        &[
+            ("FAKE_MCP_TOOL_COUNT", "1"),
+            ("FAKE_MCP_STOP_HOOK", "1"),
+            ("FAKE_MCP_STOP_TEXT", "still working"),
+        ],
+    )
+    .await;
+
+    let p = h
+        .send(
+            "session/prompt",
+            json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
+        )
+        .await;
+    let r = h.recv_until(|v| v["id"] == json!(p)).await;
+    assert!(r.get("result").is_some(), "errored: {r}");
+    assert_eq!(r["result"]["stopReason"], "end_turn");
+
+    // Three LLM calls expected: budget cap stops the loop on the 3rd end_turn.
+    let captured = llm.captured.lock().await;
+    assert_eq!(
+        captured.len(),
+        3,
+        "expected exactly 3 LLM calls (budget cap), got {}",
+        captured.len()
+    );
+    h.shutdown().await;
+}
+
+/// Consecutive-rejection rule: if the LLM responds to an objection with
+/// no tool calls and end_turn again, the agent accepts the end (avoids
+/// infinite loops with an unreasonable hook).
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn hook_stop_consecutive_end_turn() {
+    // LLM sequence:
+    //   1. text → _Stop objects (rejections: 0→1, last_was_end_turn=true)
+    //   2. text again, no tool calls → consecutive rule fires, return end_turn
+    let llm = spawn_capturing_llm(vec![openai_text("done-1"), openai_text("done-2")]).await;
+    let mut h = Harness::spawn_with_env(
+        &llm.url,
+        &[
+            ("MCP_HOOK_SERVERS", "fake"),
+            // Set high so we don't trip the budget instead.
+            ("SPROUT_AGENT_STOP_MAX_REJECTIONS", "10"),
+        ],
+    )
+    .await;
+    let sid = init_session_with_fake_mcp(
+        &mut h,
+        &[
+            ("FAKE_MCP_TOOL_COUNT", "1"),
+            ("FAKE_MCP_STOP_HOOK", "1"),
+            ("FAKE_MCP_STOP_TEXT", "keep going"),
+        ],
+    )
+    .await;
+
+    let p = h
+        .send(
+            "session/prompt",
+            json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
+        )
+        .await;
+    let r = h.recv_until(|v| v["id"] == json!(p)).await;
+    assert!(r.get("result").is_some(), "errored: {r}");
+    assert_eq!(r["result"]["stopReason"], "end_turn");
+
+    // Exactly 2 LLM calls — consecutive rule prevented a 3rd round.
+    let captured = llm.captured.lock().await;
+    assert_eq!(
+        captured.len(),
+        2,
+        "expected 2 LLM calls (consecutive rule), got {}",
+        captured.len()
+    );
+    h.shutdown().await;
+}
+
+/// Regression: an LLM that tries to call a hidden hook tool (e.g.
+/// `fake___Stop`) directly must get an "unknown tool" error result —
+/// the MCP server must NOT be invoked. This guarantees a malicious or
+/// confused model can't trigger lifecycle hooks itself.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn hook_tools_hidden_from_llm() {
+    // LLM sequence:
+    //   1. tool_call to fake___Stop (hidden hook, must fail closed)
+    //   2. text "done"
+    let llm = spawn_capturing_llm(vec![
+        openai_tool_call("tc1", "fake___Stop", json!({})),
+        openai_text("done"),
+    ])
+    .await;
+    // We deliberately leave MCP_HOOK_SERVERS unset so the
+    // agent's hook gate is disabled — hook-tool hiding must hold even
+    // when hooks aren't allowlisted (defense in depth).
+    let mut h = Harness::spawn(&llm.url).await;
+    let sid = init_session_with_fake_mcp(
+        &mut h,
+        &[
+            ("FAKE_MCP_TOOL_COUNT", "1"),
+            ("FAKE_MCP_STOP_HOOK", "1"),
+            // Distinct text we can scan for. If the MCP server is ever
+            // invoked, this string would appear in the captured history.
+            ("FAKE_MCP_STOP_TEXT", "HOOK_LEAKED_TO_LLM"),
+        ],
+    )
+    .await;
+
+    let p = h
+        .send(
+            "session/prompt",
+            json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
+        )
+        .await;
+    let r = h.recv_until(|v| v["id"] == json!(p)).await;
+    assert!(r.get("result").is_some(), "errored: {r}");
+
+    // The tool result fed back to the LLM (round 2) must be the
+    // synthetic "unknown tool" error, not the hook's actual output.
+    let captured = llm.captured.lock().await;
+    assert_eq!(
+        captured.len(),
+        2,
+        "expected 2 LLM calls, got {}",
+        captured.len()
+    );
+    let msgs = captured[1]["messages"].as_array().unwrap();
+    let tool_msg = msgs
+        .iter()
+        .find(|m| m["role"] == "tool")
+        .expect("expected a tool result message in round 2");
+    let content = tool_msg["content"].as_str().unwrap_or("");
+    assert!(
+        content.contains("unknown tool"),
+        "expected unknown-tool error, got: {content}"
+    );
+    assert!(
+        !content.contains("HOOK_LEAKED_TO_LLM"),
+        "MCP hook was invoked from the LLM path: {content}"
+    );
+
+    // Defense-in-depth: also confirm the *advertised* tools never
+    // included the hook in the first place.
+    let round1_tools = captured[0]["tools"].as_array().unwrap();
+    for t in round1_tools {
+        let name = t["function"]["name"].as_str().unwrap_or("");
+        assert!(
+            !name.contains("_Stop"),
+            "hook tool advertised to LLM: {name}"
+        );
+    }
+    h.shutdown().await;
+}
+
+/// `_PostCompact` hook fires after a context-handoff and its output is
+/// re-injected into the fresh history as a synthetic tool result. The
+/// next LLM request must therefore see the post-compact text in
+/// `messages` (role=tool) — proving the hook ran on the *new* context,
+/// not the discarded one.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn hook_post_compact_injects_after_handoff() {
+    // Sequence of canned LLM responses consumed in order:
+    //   1-3. Three `session/prompt` rounds returning short text. Each
+    //        prompt body is ~300 KB, so by the 4th prompt we'll be over
+    //        the 75% (= 768 KB) threshold of a 1 MB budget.
+    //   4.   Handoff `summarize()` call returns the summary text.
+    //   5.   Next regular `complete()` call after the handoff returns
+    //        a final "done" message; we inspect this request's body.
+    let llm = spawn_capturing_llm(vec![
+        openai_text("ack-1"),
+        openai_text("ack-2"),
+        openai_text("ack-3"),
+        openai_text("handoff summary text"),
+        openai_text("done"),
+    ])
+    .await;
+    // 1 MB budget = MIN allowed. Threshold = 768 KB. Each ~300 KB prompt
+    // fills the budget on the 4th turn, triggering handoff.
+    let mut h = Harness::spawn_with_env(
+        &llm.url,
+        &[
+            ("MCP_HOOK_SERVERS", "fake"),
+            ("SPROUT_AGENT_MAX_HISTORY_BYTES", &(1024 * 1024).to_string()),
+            // Allow at least one handoff.
+            ("SPROUT_AGENT_MAX_HANDOFFS", "3"),
+        ],
+    )
+    .await;
+    let sid = init_session_with_fake_mcp(
+        &mut h,
+        &[
+            ("FAKE_MCP_TOOL_COUNT", "1"),
+            // No _Stop hook here — _PostCompact only.
+            ("FAKE_MCP_POSTCOMPACT_HOOK", "1"),
+            ("FAKE_MCP_POSTCOMPACT_TEXT", "todo state here"),
+        ],
+    )
+    .await;
+
+    // Drive prompts until we observe a handoff. We detect it by counting
+    // captured LLM requests: a handoff inserts one extra `summarize` call
+    // that we didn't issue ourselves. We send up to 6 prompts.
+    let big = "x".repeat(300 * 1024);
+    let mut prompts_sent = 0usize;
+    let mut handoff_observed = false;
+    for i in 0..6 {
+        let p = h
+            .send(
+                "session/prompt",
+                json!({
+                    "sessionId": sid,
+                    "prompt": [{"type":"text","text": format!("{i}:{big}")}],
+                }),
+            )
+            .await;
+        let _ = h.recv_until(|v| v["id"] == json!(p)).await;
+        prompts_sent += 1;
+        let captured_now = llm.captured.lock().await.len();
+        // After N prompts we'd normally see N requests; an extra request
+        // means a handoff summarize() ran.
+        if captured_now > prompts_sent {
+            handoff_observed = true;
+            break;
+        }
+    }
+    assert!(
+        handoff_observed,
+        "no handoff observed after {prompts_sent} prompts (captured={})",
+        llm.captured.lock().await.len()
+    );
+
+    // The first LLM call AFTER the handoff is the one we inspect. Find it:
+    // it's the one where the messages array is short (history just reset)
+    // and contains a tool-role message with the _PostCompact payload.
+    let captured = llm.captured.lock().await;
+    let post_compact_visible = captured.iter().any(|req| {
+        let msgs = match req["messages"].as_array() {
+            Some(m) => m,
+            None => return false,
+        };
+        msgs.iter().any(|m| {
+            if m["role"] != "tool" {
+                return false;
+            }
+            let content = m["content"].as_str().unwrap_or("");
+            let parsed: Value = match serde_json::from_str(content) {
+                Ok(v) => v,
+                Err(_) => return false,
+            };
+            parsed["hook"] == "_PostCompact"
+                && parsed["server"] == "fake"
+                && parsed["text"] == "todo state here"
+        })
+    });
+    assert!(
+        post_compact_visible,
+        "_PostCompact tool result not visible to LLM after handoff"
+    );
+    h.shutdown().await;
+}
+
+/// `_Stop` hook that takes longer than `SPROUT_AGENT_HOOK_TIMEOUT_MS`
+/// must be treated as no-objection (fail-open). Agent stops normally.
+///
+/// Note on server-kill-on-timeout: `call_hooks` calls `kill_server` on a
+/// timed-out hook so a wedged server can't poison subsequent calls. We
+/// don't add a separate per-test assertion for this — the
+/// `mcp_init_timeout_kills_child` test already exercises the same
+/// kill-on-timeout codepath through `kill_server`, and the harness here
+/// (spawn-then-shutdown) makes a follow-up "tool still works" check
+/// fragile because the server we just killed is the only one in the
+/// session. The timeout assertion below (elapsed < 2.5s) implicitly
+/// covers the kill: if the hook child kept running past the timeout,
+/// we'd block on it during shutdown.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn hook_stop_timeout_failopen() {
+    let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
+    let mut h = Harness::spawn_with_env(
+        &llm.url,
+        &[
+            ("MCP_HOOK_SERVERS", "fake"),
+            // Hook delay (3s) >> hook timeout (200ms) → fail-open.
+            ("SPROUT_AGENT_HOOK_TIMEOUT_MS", "200"),
+        ],
+    )
+    .await;
+    let sid = init_session_with_fake_mcp(
+        &mut h,
+        &[
+            ("FAKE_MCP_TOOL_COUNT", "1"),
+            ("FAKE_MCP_STOP_HOOK", "1"),
+            ("FAKE_MCP_STOP_TEXT", "would object"),
+            ("FAKE_MCP_STOP_DELAY", "3"),
+        ],
+    )
+    .await;
+
+    let started = Instant::now();
+    let p = h
+        .send(
+            "session/prompt",
+            json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
+        )
+        .await;
+    let r = h.recv_until(|v| v["id"] == json!(p)).await;
+    let elapsed = started.elapsed();
+
+    assert!(r.get("result").is_some(), "errored: {r}");
+    assert_eq!(r["result"]["stopReason"], "end_turn");
+    // Hook delay is 3s; if we waited for it the test would take ≥3s.
+    // 1.5s gives slack for CI without masking a regression.
+    assert!(
+        elapsed < Duration::from_millis(2500),
+        "did not fail-open: prompt took {elapsed:?}"
+    );
+
+    // Only the initial LLM call — agent did NOT loop after the timeout.
+    let captured = llm.captured.lock().await;
+    assert_eq!(
+        captured.len(),
+        1,
+        "expected 1 LLM call, got {}",
+        captured.len()
+    );
+    h.shutdown().await;
+}
+
+/// When a session is cancelled while a tool call is in-flight, the agent
+/// sends `notifications/cancelled` to the MCP server. With sprout-dev-mcp,
+/// this cancels the CancellationToken and kills the running shell process
+/// group. We verify:
+///   1. The prompt completes in under 5s (not 60s).
+///   2. The `sleep 60` process is actually dead after cancel.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn cancel_kills_inflight_tool_via_mcp_notification() {
+    // sprout-dev-mcp is a separate crate; locate its binary relative to
+    // the sprout-agent test binary (they share the same target dir).
+    let self_bin = std::path::PathBuf::from(env!("CARGO_BIN_EXE_sprout-agent"));
+    let dev_mcp_bin = self_bin.parent().unwrap().join("sprout-dev-mcp");
+    if !dev_mcp_bin.exists() {
+        eprintln!(
+            "SKIP: sprout-dev-mcp not built at {}; run `cargo build -p sprout-dev-mcp` first",
+            dev_mcp_bin.display()
+        );
+        return;
+    }
+    let dev_mcp_bin = dev_mcp_bin.to_string_lossy().to_string();
+
+    // Use a unique marker (PID + timestamp) to avoid stale-file collisions.
+    let marker = format!(
+        "sprout_cancel_test_{}_{:x}",
+        std::process::id(),
+        std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .unwrap_or_default()
+            .as_nanos()
+    );
+    let pid_file = format!("/tmp/{marker}.pid");
+    let _ = std::fs::remove_file(&pid_file); // clean any stale file
+    let cmd = format!("echo $$ > /tmp/{marker}.pid && exec sleep 60");
+
+    // LLM returns a shell tool call, then text after cancel.
+    let llm = spawn_capturing_llm(vec![
+        openai_tool_call("tc1", "dev__shell", json!({"command": cmd})),
+        openai_text("done"),
+    ])
+    .await;
+
+    let mut h = Harness::spawn(&llm.url).await;
+    let sid = init_session(
+        &mut h,
+        json!([{
+            "name": "dev",
+            "command": &dev_mcp_bin,
+            "args": [],
+            "env": []
+        }]),
+    )
+    .await;
+
+    let p1 = h
+        .send(
+            "session/prompt",
+            json!({"sessionId": sid, "prompt": [{"type":"text","text":"run"}]}),
+        )
+        .await;
+
+    // Wait for the tool call to be in-progress.
+    h.recv_until(|v| {
+        v.get("params")
+            .and_then(|p| p.get("update"))
+            .and_then(|u| u.get("status"))
+            .and_then(Value::as_str)
+            == Some("in_progress")
+    })
+    .await;
+
+    // Wait for the shell to spawn and write its PID (bounded).
+    let pid_deadline = Instant::now() + Duration::from_secs(3);
+    let shell_pid: u32 = loop {
+        if let Ok(content) = std::fs::read_to_string(&pid_file) {
+            if let Ok(pid) = content.trim().parse::<u32>() {
+                break pid;
+            }
+        }
+        assert!(
+            Instant::now() < pid_deadline,
+            "shell did not write PID file within 3s"
+        );
+        tokio::time::sleep(Duration::from_millis(50)).await;
+    };
+
+    // Cancel the session — measure latency from here.
+    let cancel_start = Instant::now();
+    h.notify("session/cancel", json!({"sessionId": sid})).await;
+
+    // Wait for prompt to complete.
+    let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
+
+    let cancel_latency = cancel_start.elapsed();
+    // Cancellation itself should complete in well under 3s. The 60s sleep
+    // must NOT run to completion. We allow generous CI slack.
+    assert!(
+        cancel_latency < Duration::from_secs(3),
+        "cancel latency too high: {cancel_latency:?} (expected < 3s)"
+    );
+
+    // Verify the shell process is actually dead (bounded poll).
+    let kill_deadline = Instant::now() + Duration::from_secs(2);
+    loop {
+        let alive = std::process::Command::new("kill")
+            .args(["-0", &shell_pid.to_string()])
+            .status()
+            .map(|s| s.success())
+            .unwrap_or(false);
+        if !alive {
+            break;
+        }
+        assert!(
+            Instant::now() < kill_deadline,
+            "shell process {shell_pid} still alive 2s after cancel"
+        );
+        tokio::time::sleep(Duration::from_millis(20)).await;
+    }
+
+    // Cleanup.
+    let _ = std::fs::remove_file(&pid_file);
+    h.shutdown().await;
+}
+
+/// Protocol-level test: verify that `notifications/cancelled` is sent to
+/// any MCP server (not just sprout-dev-mcp) when a session is cancelled
+/// during an in-flight tool call. Uses fake_mcp with FAKE_MCP_CANCEL_LOG
+/// to capture the raw notification on stdin.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn cancel_sends_notifications_cancelled_to_any_mcp_server() {
+    let cancel_log = std::env::temp_dir()
+        .join(format!(
+            "sprout_cancel_proto_{}_{:x}.log",
+            std::process::id(),
+            std::time::SystemTime::now()
+                .duration_since(std::time::UNIX_EPOCH)
+                .unwrap_or_default()
+                .as_nanos()
+        ))
+        .to_string_lossy()
+        .to_string();
+    let _ = std::fs::remove_file(&cancel_log);
+    let call_received_marker = format!("{cancel_log}.call_received");
+    let _ = std::fs::remove_file(&call_received_marker);
+
+    let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
+
+    // LLM returns a tool call; fake_mcp will delay 999s (never responds).
+    let llm = spawn_capturing_llm(vec![
+        openai_tool_call("tc1", "fake__tool_0", json!({})),
+        openai_text("done"),
+    ])
+    .await;
+
+    let mut h = Harness::spawn(&llm.url).await;
+    let sid = init_session(
+        &mut h,
+        json!([{
+            "name": "fake",
+            "command": fake_mcp,
+            "args": [],
+            "env": [
+                {"name": "FAKE_MCP_TOOL_DELAY", "value": "999"},
+                {"name": "FAKE_MCP_CANCEL_LOG", "value": &cancel_log},
+                {"name": "FAKE_MCP_CALL_RECEIVED", "value": &call_received_marker},
+            ]
+        }]),
+    )
+    .await;
+
+    let p1 = h
+        .send(
+            "session/prompt",
+            json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
+        )
+        .await;
+
+    // Wait for tool call to be in-progress.
+    h.recv_until(|v| {
+        v.get("params")
+            .and_then(|p| p.get("update"))
+            .and_then(|u| u.get("status"))
+            .and_then(Value::as_str)
+            == Some("in_progress")
+    })
+    .await;
+
+    // Wait until fake_mcp has received the tools/call request (bounded).
+    // The marker file contains the JSON-RPC request id.
+    let call_deadline = Instant::now() + Duration::from_secs(3);
+    let call_request_id: Value = loop {
+        if let Ok(content) = std::fs::read_to_string(&call_received_marker) {
+            if let Ok(id) = serde_json::from_str::<Value>(content.trim()) {
+                break id;
+            }
+        }
+        assert!(
+            Instant::now() < call_deadline,
+            "fake_mcp did not receive tools/call within 3s"
+        );
+        tokio::time::sleep(Duration::from_millis(20)).await;
+    };
+
+    // Cancel the session.
+    h.notify("session/cancel", json!({"sessionId": sid})).await;
+
+    // Wait for prompt to complete.
+    let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
+
+    // Poll the cancel log with bounded timeout (replaces fixed sleep).
+    let poll_deadline = Instant::now() + Duration::from_secs(2);
+    let log_content = loop {
+        let content = std::fs::read_to_string(&cancel_log).unwrap_or_default();
+        if content.contains("notifications/cancelled") {
+            break content;
+        }
+        assert!(
+            Instant::now() < poll_deadline,
+            "cancel notification not received within 2s; log: {content:?}"
+        );
+        tokio::time::sleep(Duration::from_millis(50)).await;
+    };
+
+    // Parse the logged notification and verify requestId matches the
+    // actual tools/call request id that fake_mcp received.
+    let cancel_msg: Value = serde_json::from_str(log_content.trim()).unwrap_or(json!(null));
+    let cancelled_id = &cancel_msg["params"]["requestId"];
+    assert!(
+        cancelled_id.is_number(),
+        "expected numeric requestId in cancel notification, got: {cancel_msg}"
+    );
+    assert_eq!(
+        cancelled_id, &call_request_id,
+        "cancelled requestId ({cancelled_id}) != tools/call id ({call_request_id})"
+    );
+
+    // Cleanup.
+    let _ = std::fs::remove_file(&cancel_log);
+    let _ = std::fs::remove_file(&call_received_marker);
+    h.shutdown().await;
+}
diff --git a/crates/sprout-dev-mcp/Cargo.toml b/crates/sprout-dev-mcp/Cargo.toml
new file mode 100644
index 0000000000..b14ae3beee
--- /dev/null
+++ b/crates/sprout-dev-mcp/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "sprout-dev-mcp"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+
+[[bin]]
+name = "sprout-dev-mcp"
+path = "src/main.rs"
+
+[dependencies]
+tokio = { workspace = true }
+tokio-util = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+rmcp = { workspace = true }
+schemars = { workspace = true }
+similar = "2"
+tempfile = "3"
+ignore = "0.4.25"
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
+
+[target.'cfg(unix)'.dependencies]
+nix = { version = "0.31", default-features = false, features = ["signal", "process"] }
diff --git a/crates/sprout-dev-mcp/src/main.rs b/crates/sprout-dev-mcp/src/main.rs
new file mode 100644
index 0000000000..78c298ca6e
--- /dev/null
+++ b/crates/sprout-dev-mcp/src/main.rs
@@ -0,0 +1,142 @@
+#![forbid(unsafe_code)]
+use rmcp::{
+    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
+    model::{CallToolResult, ServerCapabilities, ServerInfo},
+    tool, tool_handler, tool_router,
+    transport::stdio,
+    ErrorData, ServerHandler, ServiceExt,
+};
+use std::path::Path;
+use std::sync::Arc;
+
+mod rg;
+mod shell;
+mod shim;
+mod str_replace;
+mod todo;
+mod tree;
+
+#[derive(Clone)]
+struct DevMcp {
+    state: Arc<shell::SharedState>,
+    todos: Arc<todo::TodoState>,
+    tool_router: ToolRouter<DevMcp>,
+}
+
+#[tool_router]
+impl DevMcp {
+    fn new(state: Arc<shell::SharedState>) -> Self {
+        Self {
+            state,
+            todos: Arc::new(todo::TodoState::new()),
+            tool_router: Self::tool_router(),
+        }
+    }
+
+    #[tool(
+        name = "shell",
+        description = "Run a bash command. Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms capped at 600000. On PATH: rg (prefer over grep; flags: -n -i -l -g <glob> -C <n> --files) and tree (flags: -d <depth>; shows line counts)."
+    )]
+    async fn shell(
+        &self,
+        Parameters(p): Parameters<shell::ShellParams>,
+        context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
+    ) -> Result<CallToolResult, ErrorData> {
+        shell::run(&self.state, p, context.ct).await
+    }
+
+    #[tool(
+        name = "str_replace",
+        description = "Atomic find-and-replace in a file. old_str must occur exactly once. Returns a unified diff. Path resolved relative to workdir (defaults to server cwd). Prefer over sed/awk."
+    )]
+    async fn str_replace(
+        &self,
+        Parameters(p): Parameters<str_replace::StrReplaceParams>,
+    ) -> Result<String, ErrorData> {
+        str_replace::run(&self.state, p)
+    }
+
+    #[tool(
+        name = "todo",
+        description = "Session task list. Omit `todos` to read current state. Provide a full replacement array to update. Items are {text, done}. Open items removed without being marked done will trigger a warning. If the operator enables hooks for this server, the agent's _Stop hook will advise against ending the turn while items are open."
+    )]
+    async fn todo(
+        &self,
+        Parameters(p): Parameters<todo::TodoParams>,
+    ) -> Result<CallToolResult, ErrorData> {
+        match self.todos.handle_todo(p) {
+            Ok(text) => todo::text_result(text),
+            Err(e) => todo::error_result(format!("Error: {e}")),
+        }
+    }
+
+    /// Hook: called by the agent before honoring end_turn. Returns
+    /// non-empty objection text iff items remain open.
+    #[tool(
+        name = "_Stop",
+        description = "Returns open todo items if any exist. Used by the agent's _Stop lifecycle hook to advise against ending with incomplete work."
+    )]
+    async fn stop_hook(
+        &self,
+        Parameters(_): Parameters<todo::HookParams>,
+    ) -> Result<CallToolResult, ErrorData> {
+        todo::text_result(self.todos.stop_objection())
+    }
+
+    /// Hook: called by the agent after context compaction/handoff so the
+    /// todo list survives history truncation.
+    #[tool(
+        name = "_PostCompact",
+        description = "Internal hook. Agent invokes after handoff; returns todo state for re-injection."
+    )]
+    async fn post_compact_hook(
+        &self,
+        Parameters(_): Parameters<todo::HookParams>,
+    ) -> Result<CallToolResult, ErrorData> {
+        todo::text_result(self.todos.post_compact())
+    }
+}
+
+#[tool_handler]
+impl ServerHandler for DevMcp {
+    fn get_info(&self) -> ServerInfo {
+        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
+            .with_server_info(rmcp::model::Implementation::new(
+                "sprout-dev-mcp",
+                env!("CARGO_PKG_VERSION"),
+            ))
+            .with_instructions(self.state.bootstrap_instructions.clone())
+    }
+}
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    tracing_subscriber::fmt()
+        .with_writer(std::io::stderr)
+        .with_ansi(false)
+        .init();
+    let argv0 = std::env::args().next().unwrap_or_default();
+    let cmd = Path::new(&argv0)
+        .file_name()
+        .and_then(|n| n.to_str())
+        .unwrap_or("")
+        .to_ascii_lowercase();
+
+    if cmd == "rg" {
+        let args: Vec<String> = std::env::args().skip(1).collect();
+        std::process::exit(rg::run(args));
+    }
+
+    if cmd == "tree" {
+        let args: Vec<String> = std::env::args().skip(1).collect();
+        std::process::exit(tree::run(args));
+    }
+
+    let cwd = std::env::current_dir()?;
+    let shim = shim::Shim::install()?;
+    let state = Arc::new(shell::SharedState::new(cwd, shim)?);
+
+    let service = DevMcp::new(state).serve(stdio()).await?;
+    service.waiting().await?;
+    Ok(())
+}
diff --git a/crates/sprout-dev-mcp/src/rg.rs b/crates/sprout-dev-mcp/src/rg.rs
new file mode 100644
index 0000000000..199b63432e
--- /dev/null
+++ b/crates/sprout-dev-mcp/src/rg.rs
@@ -0,0 +1,492 @@
+use std::io::{BufRead, BufReader};
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+const MAX_LINE_BYTES: usize = 1024 * 1024; // 1MB per line — skip files with longer lines (likely binary)
+const MAX_OUTPUT_BYTES: usize = 50 * 1024;
+const MAX_OUTPUT_LINES: usize = 2000;
+const MAX_CONTEXT: usize = 100;
+const MAX_WALK_DEPTH: usize = 50;
+
+pub fn run(args: Vec<String>) -> i32 {
+    if let Some(code) = try_system_rg(&args) {
+        return code;
+    }
+    fallback(args)
+}
+
+fn try_system_rg(args: &[String]) -> Option<i32> {
+    let self_exe = std::env::current_exe().ok()?;
+    let self_canon = std::fs::canonicalize(&self_exe).ok()?;
+    let cleaned_path = clean_path(&self_canon);
+    let candidate = which_rg(&cleaned_path)?;
+
+    let status = Command::new(&candidate)
+        .args(args)
+        .env("PATH", &cleaned_path)
+        .status()
+        .ok()?;
+    Some(status.code().unwrap_or(2))
+}
+
+fn clean_path(self_canon: &Path) -> String {
+    let original = std::env::var("PATH").unwrap_or_default();
+    original
+        .split(':')
+        .filter(|dir| {
+            if dir.is_empty() {
+                return false;
+            }
+            let candidate = Path::new(dir).join("rg");
+            match std::fs::canonicalize(&candidate) {
+                Ok(c) => c != *self_canon,
+                Err(_) => true,
+            }
+        })
+        .collect::<Vec<_>>()
+        .join(":")
+}
+
+fn which_rg(path: &str) -> Option<PathBuf> {
+    for dir in path.split(':') {
+        if dir.is_empty() {
+            continue;
+        }
+        let candidate = Path::new(dir).join("rg");
+        if candidate.is_file() && is_executable(&candidate) {
+            return Some(candidate);
+        }
+    }
+    None
+}
+
+#[cfg(unix)]
+fn is_executable(p: &Path) -> bool {
+    use std::os::unix::fs::PermissionsExt;
+    std::fs::metadata(p)
+        .map(|m| m.permissions().mode() & 0o111 != 0)
+        .unwrap_or(false)
+}
+
+#[cfg(not(unix))]
+fn is_executable(_p: &Path) -> bool {
+    true
+}
+
+#[cfg_attr(test, derive(Debug))]
+struct RgArgs {
+    pattern: Option<String>,
+    paths: Vec<PathBuf>,
+    line_numbers: bool,
+    ignore_case: bool,
+    files_only: bool,
+    list_files_with_matches: bool,
+    context: usize,
+    glob: Option<String>,
+}
+
+fn parse(args: Vec<String>) -> Result<RgArgs, String> {
+    let mut out = RgArgs {
+        pattern: None,
+        paths: Vec::new(),
+        line_numbers: false,
+        ignore_case: false,
+        files_only: false,
+        list_files_with_matches: false,
+        context: 0,
+        glob: None,
+    };
+    let mut iter = args.into_iter();
+    let mut positional: Vec<String> = Vec::new();
+    while let Some(a) = iter.next() {
+        match a.as_str() {
+            "--files" => out.files_only = true,
+            "-n" | "--line-number" => out.line_numbers = true,
+            "-i" | "--ignore-case" => out.ignore_case = true,
+            "-l" | "--files-with-matches" => out.list_files_with_matches = true,
+            "-C" | "--context" => {
+                let n = iter.next().ok_or("missing value for -C")?;
+                let parsed: usize = n.parse().map_err(|_| format!("bad -C value: {n}"))?;
+                out.context = parsed.min(MAX_CONTEXT);
+            }
+            "-g" | "--glob" => {
+                out.glob = Some(iter.next().ok_or("missing value for -g")?);
+            }
+            "--" => positional.extend(iter.by_ref()),
+            s if s.starts_with('-') && s.len() > 1 => {
+                return Err(format!("unsupported flag (fallback rg): {s}"));
+            }
+            _ => positional.push(a),
+        }
+    }
+    if out.files_only {
+        out.paths = positional.into_iter().map(PathBuf::from).collect();
+        if out.paths.is_empty() {
+            out.paths.push(PathBuf::from("."));
+        }
+    } else {
+        let mut it = positional.into_iter();
+        out.pattern = it.next();
+        out.paths = it.map(PathBuf::from).collect();
+        if out.paths.is_empty() {
+            out.paths.push(PathBuf::from("."));
+        }
+    }
+    Ok(out)
+}
+
+struct CappedSink {
+    bytes: usize,
+    lines: usize,
+    capped: bool,
+}
+
+impl CappedSink {
+    fn new() -> Self {
+        Self {
+            bytes: 0,
+            lines: 0,
+            capped: false,
+        }
+    }
+
+    fn writeln(&mut self, s: &str) {
+        if self.capped {
+            return;
+        }
+        let next = self.bytes.saturating_add(s.len()).saturating_add(1);
+        if next > MAX_OUTPUT_BYTES || self.lines >= MAX_OUTPUT_LINES {
+            self.capped = true;
+            tracing::warn!("rg (fallback): output capped at {MAX_OUTPUT_BYTES} bytes / {MAX_OUTPUT_LINES} lines");
+            return;
+        }
+        println!("{s}");
+        self.bytes = next;
+        self.lines += 1;
+    }
+}
+
+fn fallback(args: Vec<String>) -> i32 {
+    let opts = match parse(args) {
+        Ok(o) => o,
+        Err(e) => {
+            tracing::error!("rg (fallback): {e}");
+            return 2;
+        }
+    };
+    let mut sink = CappedSink::new();
+    let mut found = false;
+    let mut printed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
+
+    if opts.files_only {
+        for root in &opts.paths {
+            walk(root, &opts, &mut |p| {
+                sink.writeln(&p.display().to_string());
+                found = true;
+                !sink.capped
+            });
+            if sink.capped {
+                break;
+            }
+        }
+        return if found { 0 } else { 1 };
+    }
+
+    let pattern = match &opts.pattern {
+        Some(p) => p.clone(),
+        None => {
+            tracing::error!("rg (fallback): missing PATTERN");
+            return 2;
+        }
+    };
+    let needle = if opts.ignore_case {
+        pattern.to_lowercase()
+    } else {
+        pattern
+    };
+
+    for root in &opts.paths {
+        walk(root, &opts, &mut |path| {
+            if sink.capped {
+                return false;
+            }
+            if scan_file(path, &needle, &opts, &mut sink, &mut printed) {
+                found = true;
+            }
+            !sink.capped
+        });
+        if sink.capped {
+            break;
+        }
+    }
+    if found {
+        0
+    } else {
+        1
+    }
+}
+
+fn read_bounded_line(reader: &mut impl BufRead, max: usize) -> Option<Result<String, ()>> {
+    let mut buf = Vec::new();
+    loop {
+        let available = match reader.fill_buf() {
+            Ok([]) => {
+                if buf.is_empty() {
+                    return None;
+                }
+                return match String::from_utf8(buf) {
+                    Ok(s) => Some(Ok(s)),
+                    Err(_) => Some(Err(())),
+                };
+            }
+            Ok(b) => b,
+            Err(_) => return None,
+        };
+        let take = available
+            .iter()
+            .position(|b| *b == b'\n')
+            .map_or(available.len(), |i| i + 1);
+        if buf.len() + take > max {
+            return Some(Err(()));
+        }
+        buf.extend_from_slice(&available[..take]);
+        reader.consume(take);
+        if buf.ends_with(b"\n") {
+            buf.pop();
+            return match String::from_utf8(buf) {
+                Ok(s) => Some(Ok(s)),
+                Err(_) => Some(Err(())),
+            };
+        }
+    }
+}
+
+fn scan_file(
+    path: &Path,
+    needle: &str,
+    opts: &RgArgs,
+    sink: &mut CappedSink,
+    printed: &mut std::collections::HashSet<PathBuf>,
+) -> bool {
+    let file = match std::fs::File::open(path) {
+        Ok(f) => f,
+        Err(_) => return false,
+    };
+    let cap = opts.context;
+    let mut ring: std::collections::VecDeque<String> =
+        std::collections::VecDeque::with_capacity(cap);
+    let mut tail = 0usize;
+    let mut last: Option<usize> = None;
+    let mut found = false;
+
+    let mut reader = BufReader::new(file);
+    let mut idx = 0usize;
+    loop {
+        let line = match read_bounded_line(&mut reader, MAX_LINE_BYTES) {
+            None => break,
+            Some(Err(())) => return found,
+            Some(Ok(l)) => l,
+        };
+        if sink.capped {
+            return found;
+        }
+        let is_match = if opts.ignore_case {
+            line.to_lowercase().contains(needle)
+        } else {
+            line.contains(needle)
+        };
+
+        if is_match {
+            found = true;
+            if opts.list_files_with_matches {
+                if printed.insert(path.to_path_buf()) {
+                    sink.writeln(&path.display().to_string());
+                }
+                return found;
+            }
+            let ring_start = idx.saturating_sub(ring.len());
+            for (offset, prev) in ring.iter().enumerate() {
+                let li = ring_start + offset;
+                if last.is_none_or(|l| li > l) {
+                    emit_line(path, li, prev, opts, sink);
+                    last = Some(li);
+                }
+            }
+            if last.is_none_or(|l| idx > l) {
+                emit_line(path, idx, &line, opts, sink);
+                last = Some(idx);
+            }
+            tail = cap;
+        } else if tail > 0 {
+            emit_line(path, idx, &line, opts, sink);
+            last = Some(idx);
+            tail -= 1;
+        }
+
+        if cap > 0 {
+            if ring.len() == cap {
+                ring.pop_front();
+            }
+            ring.push_back(line);
+        }
+        idx += 1;
+    }
+    found
+}
+
+fn emit_line(path: &Path, line_idx: usize, line: &str, opts: &RgArgs, sink: &mut CappedSink) {
+    let prefix = if opts.line_numbers {
+        format!("{}:{}:", path.display(), line_idx + 1)
+    } else {
+        format!("{}:", path.display())
+    };
+    sink.writeln(&format!("{prefix}{line}"));
+}
+
+fn walk(root: &Path, opts: &RgArgs, on_file: &mut dyn FnMut(&Path) -> bool) {
+    if root.is_file() {
+        if accept(root, opts) {
+            on_file(root);
+        }
+        return;
+    }
+    let mut stack: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)];
+    while let Some((dir, depth)) = stack.pop() {
+        let entries = match std::fs::read_dir(&dir) {
+            Ok(e) => e,
+            Err(_) => continue,
+        };
+        for entry in entries.flatten() {
+            let path = entry.path();
+            let name = match path.file_name().and_then(|n| n.to_str()) {
+                Some(n) => n,
+                None => continue,
+            };
+            if name.starts_with('.') {
+                continue;
+            }
+            let ft = match entry.file_type() {
+                Ok(t) => t,
+                Err(_) => continue,
+            };
+            if ft.is_symlink() {
+                continue;
+            }
+            if ft.is_dir() {
+                if matches!(name, "target" | "node_modules" | "dist" | "build") {
+                    continue;
+                }
+                if depth < MAX_WALK_DEPTH {
+                    stack.push((path, depth + 1));
+                }
+            } else if ft.is_file() && accept(&path, opts) && !on_file(&path) {
+                return;
+            }
+        }
+    }
+}
+
+fn accept(path: &Path, opts: &RgArgs) -> bool {
+    match &opts.glob {
+        None => true,
+        Some(g) => glob_match(g, path),
+    }
+}
+
+fn glob_match(pattern: &str, path: &Path) -> bool {
+    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
+    let full = path.to_string_lossy();
+    simple_glob(pattern, name) || simple_glob(pattern, &full)
+}
+
+fn simple_glob(pattern: &str, text: &str) -> bool {
+    let p: Vec<char> = pattern.chars().collect();
+    let t: Vec<char> = text.chars().collect();
+
+    let (mut pi, mut ti) = (0usize, 0usize);
+    let (mut star_pi, mut star_ti) = (usize::MAX, 0usize);
+
+    while ti < t.len() {
+        if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
+            pi += 1;
+            ti += 1;
+        } else if pi < p.len() && p[pi] == '*' {
+            star_pi = pi;
+            star_ti = ti;
+            pi += 1;
+        } else if star_pi != usize::MAX {
+            pi = star_pi + 1;
+            star_ti += 1;
+            ti = star_ti;
+        } else {
+            return false;
+        }
+    }
+
+    while pi < p.len() && p[pi] == '*' {
+        pi += 1;
+    }
+
+    pi == p.len()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parse_basic_pattern_and_path() {
+        let opts = parse(vec!["-n".into(), "needle".into(), "src".into()]).expect("parse");
+        assert!(opts.line_numbers);
+        assert_eq!(opts.pattern.as_deref(), Some("needle"));
+        assert_eq!(opts.paths, vec![PathBuf::from("src")]);
+    }
+
+    #[test]
+    fn parse_files_only() {
+        let opts = parse(vec!["--files".into(), ".".into()]).expect("parse");
+        assert!(opts.files_only);
+        assert_eq!(opts.paths, vec![PathBuf::from(".")]);
+    }
+
+    #[test]
+    fn parse_rejects_unknown_flag() {
+        let err = parse(vec!["-Z".into(), "x".into()]).unwrap_err();
+        assert!(err.contains("unsupported flag"));
+    }
+
+    #[test]
+    fn capped_sink_stops_at_byte_limit() {
+        let mut s = CappedSink::new();
+        // Below cap: accepted.
+        s.bytes = MAX_OUTPUT_BYTES - 10;
+        s.writeln("12345");
+        assert!(!s.capped);
+        // This pushes us over.
+        s.writeln("x".repeat(20).as_str());
+        assert!(s.capped);
+    }
+
+    #[test]
+    fn glob_matches_simple_patterns() {
+        assert!(simple_glob("*.rs", "main.rs"));
+        assert!(simple_glob("src/*.rs", "src/main.rs"));
+        assert!(!simple_glob("*.rs", "main.txt"));
+        assert!(simple_glob("a?c", "abc"));
+    }
+
+    #[test]
+    fn fallback_finds_match_in_file() {
+        // End-to-end: create a file, run the fallback parser, scan it.
+        // We can't easily capture stdout here, so we verify scan_file
+        // returns true (match found).
+        let dir = tempfile::tempdir().expect("tempdir");
+        let f = dir.path().join("a.txt");
+        std::fs::write(&f, "alpha\nNEEDLE\nbeta\n").expect("write");
+        let opts = parse(vec!["NEEDLE".into(), f.display().to_string()]).expect("parse");
+        let mut sink = CappedSink::new();
+        let mut printed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
+        let found = scan_file(&f, "NEEDLE", &opts, &mut sink, &mut printed);
+        assert!(found, "expected NEEDLE to be found");
+    }
+}
diff --git a/crates/sprout-dev-mcp/src/shell.rs b/crates/sprout-dev-mcp/src/shell.rs
new file mode 100644
index 0000000000..89f4fee60e
--- /dev/null
+++ b/crates/sprout-dev-mcp/src/shell.rs
@@ -0,0 +1,551 @@
+use crate::shim::Shim;
+use rmcp::model::{CallToolResult, Content};
+use rmcp::ErrorData;
+use schemars::JsonSchema;
+use serde::Deserialize;
+use std::collections::VecDeque;
+use std::path::{Path, PathBuf};
+use std::process::Stdio;
+use std::sync::Mutex;
+use std::time::{Duration, Instant};
+use tempfile::TempDir;
+use tokio::io::{AsyncRead, AsyncReadExt};
+use tokio::process::Command;
+use tokio_util::sync::CancellationToken;
+
+const DEFAULT_TIMEOUT_MS: u64 = 120_000;
+const MAX_TIMEOUT_MS: u64 = 600_000;
+const MAX_COMMAND_BYTES: usize = 1_000_000;
+const CAPTURE_CAP: usize = 10 * 1024 * 1024;
+const MAX_BYTES: usize = 50 * 1024;
+const MAX_LINES: usize = 2000;
+const TAIL_BYTES: usize = 8 * 1024;
+const ARTIFACT_RING_SIZE: usize = 8;
+const READ_CHUNK: usize = 16 * 1024;
+
+pub struct SharedState {
+    pub cwd: PathBuf,
+    pub shim: Shim,
+    pub session_dir: TempDir,
+    pub bootstrap_instructions: String,
+    pub artifacts: Mutex<VecDeque<PathBuf>>,
+    next_call_id: Mutex<u64>,
+}
+
+impl SharedState {
+    pub fn new(cwd: PathBuf, shim: Shim) -> std::io::Result<Self> {
+        let session_dir = tempfile::Builder::new()
+            .prefix("sprout-dev-mcp-session-")
+            .tempdir()?;
+        let bootstrap_instructions = build_bootstrap(&cwd);
+        Ok(Self {
+            cwd,
+            shim,
+            session_dir,
+            bootstrap_instructions,
+            artifacts: Mutex::new(VecDeque::with_capacity(ARTIFACT_RING_SIZE)),
+            next_call_id: Mutex::new(0),
+        })
+    }
+
+    fn next_id(&self) -> u64 {
+        let mut g = match self.next_call_id.lock() {
+            Ok(g) => g,
+            Err(p) => p.into_inner(),
+        };
+        *g += 1;
+        *g
+    }
+}
+
+fn build_bootstrap(cwd: &Path) -> String {
+    let stack = detect_stack(cwd);
+    format!(
+        "Working directory: {}\n\
+         Detected stack: {}\n\
+         Pass `workdir` per call rather than `cd`.\n",
+        cwd.display(),
+        stack,
+    )
+}
+
+fn detect_stack(cwd: &Path) -> String {
+    let markers = [
+        ("Cargo.toml", "rust (cargo)"),
+        ("package.json", "node"),
+        ("go.mod", "go"),
+        ("pyproject.toml", "python (pyproject)"),
+        ("requirements.txt", "python"),
+        ("Gemfile", "ruby"),
+        ("pom.xml", "java (maven)"),
+        ("build.gradle", "java (gradle)"),
+        ("build.gradle.kts", "kotlin (gradle)"),
+    ];
+    let mut found: Vec<&str> = markers
+        .iter()
+        .filter(|(f, _)| cwd.join(f).exists())
+        .map(|(_, name)| *name)
+        .collect();
+    if found.is_empty() {
+        "unknown".into()
+    } else {
+        found.sort();
+        found.join(", ")
+    }
+}
+
+#[derive(Debug, Deserialize, JsonSchema)]
+pub struct ShellParams {
+    pub command: String,
+    #[serde(default)]
+    pub workdir: Option<String>,
+    #[serde(default)]
+    pub timeout_ms: Option<u64>,
+}
+
+pub async fn run(
+    state: &SharedState,
+    p: ShellParams,
+    ct: CancellationToken,
+) -> Result<CallToolResult, ErrorData> {
+    if p.command.len() > MAX_COMMAND_BYTES {
+        return Err(ErrorData::invalid_params(
+            format!("command exceeds {MAX_COMMAND_BYTES} byte limit"),
+            None,
+        ));
+    }
+    let timeout_ms = p
+        .timeout_ms
+        .unwrap_or(DEFAULT_TIMEOUT_MS)
+        .min(MAX_TIMEOUT_MS);
+    let workdir: PathBuf = p
+        .workdir
+        .as_deref()
+        .map(PathBuf::from)
+        .unwrap_or_else(|| state.cwd.clone());
+
+    if !workdir.is_dir() {
+        return Err(ErrorData::invalid_params(
+            format!(
+                "workdir does not exist or is not a directory: {}",
+                workdir.display()
+            ),
+            None,
+        ));
+    }
+
+    let mut cmd = Command::new("bash");
+    cmd.arg("-c").arg(&p.command);
+    cmd.current_dir(&workdir);
+    cmd.env("PATH", &state.shim.path_env);
+    cmd.stdin(Stdio::null());
+    cmd.stdout(Stdio::piped());
+    cmd.stderr(Stdio::piped());
+    cmd.kill_on_drop(true);
+    set_process_group(&mut cmd);
+
+    let started = Instant::now();
+    let mut child = match cmd.spawn() {
+        Ok(c) => c,
+        Err(e) => {
+            return Ok(CallToolResult::error(vec![Content::text(format!(
+                "failed to spawn bash: {e}"
+            ))]));
+        }
+    };
+
+    let pid = child.id();
+
+    struct PgidGuard(Option<u32>);
+    impl Drop for PgidGuard {
+        fn drop(&mut self) {
+            if let Some(pid) = self.0 {
+                kill_process_group_immediate(pid as i32);
+            }
+        }
+    }
+    let mut pgid_guard = PgidGuard(pid);
+
+    let stdout_pipe = child.stdout.take();
+    let stderr_pipe = child.stderr.take();
+
+    let mut stdout_handle = tokio::spawn(async move {
+        match stdout_pipe {
+            Some(p) => read_capped(p).await,
+            None => CapturedStream::default(),
+        }
+    });
+    let mut stderr_handle = tokio::spawn(async move {
+        match stderr_pipe {
+            Some(p) => read_capped(p).await,
+            None => CapturedStream::default(),
+        }
+    });
+
+    let timeout_dur = Duration::from_millis(timeout_ms);
+    let mut notes: Vec<String> = Vec::new();
+    let (status, timed_out) = tokio::select! {
+        biased;
+        _ = ct.cancelled() => {
+            // Kill process group, reap child, abort reader tasks.
+            if let Some(pid) = pid {
+                kill_process_group_immediate(pid as i32);
+            }
+            // Bounded reap so we don't leak zombies. If reap times out,
+            // PgidGuard drop will SIGKILL again as a last resort.
+            match tokio::time::timeout(Duration::from_secs(1), child.wait()).await {
+                Ok(Ok(_)) => { pgid_guard.0 = None; } // reaped; disarm guard
+                Ok(Err(e)) => {
+                    tracing::debug!("cancel: child wait error: {e}");
+                    // Leave pgid_guard armed for drop-kill.
+                }
+                Err(_) => {
+                    tracing::debug!("cancel: child reap timed out; guard will SIGKILL on drop");
+                }
+            }
+            stdout_handle.abort();
+            stderr_handle.abort();
+            return Ok(CallToolResult::error(vec![Content::text("cancelled")]));
+        }
+        r = tokio::time::timeout(timeout_dur, child.wait()) => match r {
+        Ok(Ok(s)) => (Some(s), false),
+        Ok(Err(err)) => {
+            notes.push(format!("child wait failed: {err}"));
+            (None, false)
+        }
+        Err(_) => {
+            // Kill process group — this closes the pipes, causing reads to EOF.
+            if let Some(pid) = pid {
+                kill_process_group_graceful(pid as i32).await;
+            }
+            // Reap the child so it doesn't become a zombie.
+            let deadline = Instant::now() + Duration::from_secs(2);
+            loop {
+                match child.try_wait() {
+                    Ok(Some(_)) => break,
+                    Ok(None) if Instant::now() >= deadline => {
+                        if let Err(e) = child.start_kill() {
+                            notes.push(format!("force-kill failed: {e}"));
+                        }
+                        if let Err(e) = child.wait().await {
+                            notes.push(format!("post-kill wait: {e}"));
+                        }
+                        break;
+                    }
+                    Ok(None) => {
+                        tokio::time::sleep(Duration::from_millis(20)).await;
+                    }
+                    Err(err) => {
+                        notes.push(format!("try_wait failed: {err}"));
+                        break;
+                    }
+                }
+            }
+            (None, true)
+        }
+        }
+    };
+
+    if !timed_out {
+        if let Some(pid) = pid {
+            kill_process_group_graceful(pid as i32).await;
+        }
+    }
+
+    let stdout_cap = match tokio::time::timeout(Duration::from_secs(5), &mut stdout_handle).await {
+        Ok(Ok(cap)) => cap,
+        _ => {
+            stdout_handle.abort();
+            notes.push("stdout reader did not complete".into());
+            CapturedStream::default()
+        }
+    };
+    let stderr_cap = match tokio::time::timeout(Duration::from_secs(5), &mut stderr_handle).await {
+        Ok(Ok(cap)) => cap,
+        _ => {
+            stderr_handle.abort();
+            notes.push("stderr reader did not complete".into());
+            CapturedStream::default()
+        }
+    };
+
+    let duration_ms = started.elapsed().as_millis() as u64;
+    let exit_code = status
+        .as_ref()
+        .and_then(|s| s.code())
+        .unwrap_or(if timed_out { 124 } else { -1 });
+
+    let id = state.next_id();
+    let (stdout_text, stdout_truncated, stdout_artifact) =
+        finalize_stream(state, id, "stdout", stdout_cap, &mut notes);
+    let (stderr_text, stderr_truncated, stderr_artifact) =
+        finalize_stream(state, id, "stderr", stderr_cap, &mut notes);
+
+    let body = serde_json::json!({
+        "exit_code": exit_code,
+        "stdout": stdout_text,
+        "stderr": stderr_text,
+        "timed_out": timed_out,
+        "duration_ms": duration_ms,
+        "stdout_truncated": stdout_truncated,
+        "stderr_truncated": stderr_truncated,
+        "stdout_artifact": stdout_artifact,
+        "stderr_artifact": stderr_artifact,
+        "notes": notes,
+    });
+    let text = serde_json::to_string_pretty(&body).unwrap_or_else(|_| "{}".into());
+    pgid_guard.0 = None;
+    Ok(CallToolResult::success(vec![Content::text(text)]))
+}
+
+#[cfg(unix)]
+fn set_process_group(cmd: &mut Command) {
+    cmd.process_group(0);
+}
+
+#[cfg(not(unix))]
+fn set_process_group(_cmd: &mut Command) {}
+
+/// Immediate SIGKILL of the process group. Sync; safe to call from Drop.
+/// No grace period — used when the parent task is being torn down.
+#[cfg(unix)]
+fn kill_process_group_immediate(pid: i32) {
+    use nix::sys::signal::{killpg, Signal};
+    use nix::unistd::Pid;
+    let _ = killpg(Pid::from_raw(pid), Signal::SIGKILL);
+}
+
+#[cfg(not(unix))]
+fn kill_process_group_immediate(_pid: i32) {}
+
+/// Graceful SIGTERM → 200ms async sleep → SIGKILL. Async; never blocks the runtime.
+#[cfg(unix)]
+async fn kill_process_group_graceful(pid: i32) {
+    use nix::sys::signal::{killpg, Signal};
+    use nix::unistd::Pid;
+    let pgid = Pid::from_raw(pid);
+    let _ = killpg(pgid, Signal::SIGTERM);
+    tokio::time::sleep(Duration::from_millis(200)).await;
+    let _ = killpg(pgid, Signal::SIGKILL);
+}
+
+#[cfg(not(unix))]
+async fn kill_process_group_graceful(_pid: i32) {}
+
+#[derive(Default)]
+struct CapturedStream {
+    bytes: Vec<u8>,
+    /// Total bytes the process produced (may exceed bytes.len() if capped).
+    total_bytes: usize,
+    capped: bool,
+}
+
+async fn read_capped<R: AsyncRead + Unpin>(mut r: R) -> CapturedStream {
+    let mut out = CapturedStream::default();
+    let mut chunk = vec![0u8; READ_CHUNK];
+    loop {
+        match r.read(&mut chunk).await {
+            Ok(0) => break,
+            Ok(n) => {
+                out.total_bytes = out.total_bytes.saturating_add(n);
+                if !out.capped {
+                    let remaining = CAPTURE_CAP.saturating_sub(out.bytes.len());
+                    if remaining == 0 {
+                        out.capped = true;
+                    } else {
+                        let take = n.min(remaining);
+                        out.bytes.extend_from_slice(&chunk[..take]);
+                        if out.bytes.len() >= CAPTURE_CAP {
+                            out.capped = true;
+                        }
+                    }
+                }
+            }
+            Err(_) => break,
+        }
+    }
+    out
+}
+
+fn finalize_stream(
+    state: &SharedState,
+    call_id: u64,
+    label: &str,
+    cap: CapturedStream,
+    notes: &mut Vec<String>,
+) -> (String, bool, Option<String>) {
+    let CapturedStream {
+        bytes: buf,
+        total_bytes,
+        capped,
+    } = cap;
+    let captured_len = buf.len();
+    let line_count = buf.iter().filter(|b| **b == b'\n').count();
+    let needs_truncate = capped || captured_len > MAX_BYTES || line_count > MAX_LINES;
+
+    if !needs_truncate {
+        return (lossy(buf), false, None);
+    }
+
+    let artifact_path = crate::shim::artifact_dir(state.session_dir.path())
+        .join(format!("{call_id:06}.{label}.txt"));
+    let artifact_str = match std::fs::write(&artifact_path, &buf) {
+        Ok(()) => {
+            rotate_artifacts(state, artifact_path.clone());
+            Some(artifact_path.to_string_lossy().into_owned())
+        }
+        Err(e) => {
+            notes.push(format!(
+                "{label}: artifact write failed ({}): {e}",
+                artifact_path.display()
+            ));
+            None
+        }
+    };
+
+    let tail_start = captured_len.saturating_sub(TAIL_BYTES);
+    let tail_aligned = align_to_char_boundary(&buf, tail_start);
+    let tail = lossy(buf[tail_aligned..].to_vec());
+
+    let cap_note = if capped {
+        format!(
+            " (capture capped at {} bytes; further output discarded)",
+            CAPTURE_CAP
+        )
+    } else {
+        String::new()
+    };
+    let artifact_suffix = match &artifact_str {
+        Some(p) => format!("; captured output (first 10MB) at {p}"),
+        None => "; artifact unavailable".into(),
+    };
+    let notice = format!(
+        "[truncated: showing last {} bytes; {} bytes captured / {} lines / {} bytes total{cap_note}{artifact_suffix}]\n",
+        tail.len(),
+        captured_len,
+        line_count,
+        total_bytes,
+    );
+    let mut out = String::with_capacity(notice.len() + tail.len());
+    out.push_str(&notice);
+    out.push_str(&tail);
+    (out, true, artifact_str)
+}
+
+fn align_to_char_boundary(buf: &[u8], start: usize) -> usize {
+    let mut i = start.min(buf.len());
+    while i < buf.len() && (buf[i] & 0xC0) == 0x80 {
+        i += 1;
+    }
+    i
+}
+
+fn lossy(buf: Vec<u8>) -> String {
+    String::from_utf8(buf).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
+}
+
+fn rotate_artifacts(state: &SharedState, new_path: PathBuf) {
+    let mut ring = match state.artifacts.lock() {
+        Ok(g) => g,
+        Err(p) => p.into_inner(),
+    };
+    ring.push_back(new_path);
+    while ring.len() > ARTIFACT_RING_SIZE {
+        if let Some(old) = ring.pop_front() {
+            let _ = std::fs::remove_file(old);
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::shim::Shim;
+    use serde_json::Value;
+    use tempfile::tempdir;
+
+    fn make_state(cwd: &std::path::Path) -> SharedState {
+        let shim = Shim::install().expect("shim install");
+        SharedState::new(cwd.to_path_buf(), shim).expect("state new")
+    }
+
+    /// Pull the JSON body out of a CallToolResult so tests can assert on fields.
+    fn body(r: rmcp::model::CallToolResult) -> Value {
+        let text = match r.content.first().and_then(|c| c.as_text()) {
+            Some(t) => t.text.clone(),
+            None => panic!("no text content"),
+        };
+        serde_json::from_str(&text).expect("json")
+    }
+
+    #[tokio::test(flavor = "current_thread")]
+    async fn basic_echo() {
+        let dir = tempdir().expect("tempdir");
+        let state = make_state(dir.path());
+        let r = run(
+            &state,
+            ShellParams {
+                command: "echo hello".into(),
+                workdir: None,
+                timeout_ms: Some(5_000),
+            },
+            CancellationToken::new(),
+        )
+        .await
+        .expect("ok");
+        let v = body(r);
+        assert_eq!(v["exit_code"], 0);
+        assert_eq!(v["stdout"], "hello\n");
+        assert_eq!(v["timed_out"], false);
+    }
+
+    #[tokio::test(flavor = "current_thread")]
+    async fn timeout_fires() {
+        let dir = tempdir().expect("tempdir");
+        let state = make_state(dir.path());
+        let r = run(
+            &state,
+            ShellParams {
+                command: "sleep 999".into(),
+                workdir: None,
+                timeout_ms: Some(150),
+            },
+            CancellationToken::new(),
+        )
+        .await
+        .expect("ok");
+        let v = body(r);
+        assert_eq!(v["timed_out"], true);
+        assert_eq!(v["exit_code"], 124);
+    }
+
+    #[tokio::test(flavor = "current_thread")]
+    async fn workdir_is_honored() {
+        let dir = tempdir().expect("tempdir");
+        let sub = dir.path().join("sub");
+        std::fs::create_dir(&sub).expect("mkdir sub");
+        let state = make_state(dir.path());
+        let r = run(
+            &state,
+            ShellParams {
+                command: "pwd".into(),
+                workdir: Some(sub.display().to_string()),
+                timeout_ms: Some(5_000),
+            },
+            CancellationToken::new(),
+        )
+        .await
+        .expect("ok");
+        let v = body(r);
+        let stdout = v["stdout"].as_str().unwrap_or("");
+        // Compare canonicalized paths (macOS /tmp -> /private/tmp, etc.).
+        let sub_canon = std::fs::canonicalize(&sub).expect("canon");
+        assert!(
+            stdout
+                .trim()
+                .ends_with(sub_canon.to_string_lossy().as_ref())
+                || stdout.contains(sub.file_name().unwrap().to_str().unwrap()),
+            "stdout: {stdout}"
+        );
+    }
+}
diff --git a/crates/sprout-dev-mcp/src/shim.rs b/crates/sprout-dev-mcp/src/shim.rs
new file mode 100644
index 0000000000..2cfee3f82b
--- /dev/null
+++ b/crates/sprout-dev-mcp/src/shim.rs
@@ -0,0 +1,65 @@
+use std::path::{Path, PathBuf};
+use tempfile::TempDir;
+
+pub struct Shim {
+    _dir: TempDir,
+    pub path_env: String,
+}
+
+impl Shim {
+    pub fn install() -> std::io::Result<Self> {
+        let dir = tempfile::Builder::new()
+            .prefix("sprout-dev-mcp-")
+            .tempdir()?;
+        set_owner_only(dir.path())?;
+
+        let self_exe = std::env::current_exe()?;
+        let rg_link = dir.path().join("rg");
+        symlink(&self_exe, &rg_link)?;
+
+        let tree_link = dir.path().join("tree");
+        symlink(&self_exe, &tree_link)?;
+
+        let original = std::env::var_os("PATH").unwrap_or_default();
+        let mut new_path = std::ffi::OsString::from(dir.path());
+        if !original.is_empty() {
+            new_path.push(":");
+            new_path.push(&original);
+        }
+        let path_env = new_path.to_string_lossy().into_owned();
+
+        Ok(Self {
+            _dir: dir,
+            path_env,
+        })
+    }
+}
+
+#[cfg(unix)]
+fn set_owner_only(path: &Path) -> std::io::Result<()> {
+    use std::os::unix::fs::PermissionsExt;
+    let mut perms = std::fs::metadata(path)?.permissions();
+    perms.set_mode(0o700);
+    std::fs::set_permissions(path, perms)
+}
+
+#[cfg(not(unix))]
+fn set_owner_only(_: &Path) -> std::io::Result<()> {
+    Ok(())
+}
+
+#[cfg(unix)]
+fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
+    std::os::unix::fs::symlink(src, dst)
+}
+
+#[cfg(not(unix))]
+fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
+    std::fs::copy(src, dst).map(|_| ())
+}
+
+pub fn artifact_dir(session_root: &Path) -> PathBuf {
+    let p = session_root.join("artifacts");
+    let _ = std::fs::create_dir_all(&p);
+    p
+}
diff --git a/crates/sprout-dev-mcp/src/str_replace.rs b/crates/sprout-dev-mcp/src/str_replace.rs
new file mode 100644
index 0000000000..0a788bd0e9
--- /dev/null
+++ b/crates/sprout-dev-mcp/src/str_replace.rs
@@ -0,0 +1,386 @@
+use crate::shell::SharedState;
+use rmcp::ErrorData;
+use schemars::JsonSchema;
+use serde::Deserialize;
+use similar::{DiffTag, TextDiff};
+use std::io::Write;
+use std::path::{Path, PathBuf};
+
+const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
+const MAX_INPUT_BYTES: usize = 1024 * 1024;
+const HINT_SCAN_LINE_LIMIT: usize = 200;
+
+#[derive(Debug, Deserialize, JsonSchema)]
+pub struct StrReplaceParams {
+    pub path: String,
+    pub old_str: String,
+    pub new_str: String,
+    #[serde(default)]
+    pub workdir: Option<String>,
+}
+
+pub fn run(state: &SharedState, p: StrReplaceParams) -> Result<String, ErrorData> {
+    if p.old_str.is_empty() {
+        return Err(ErrorData::invalid_params(
+            "old_str must not be empty".to_string(),
+            None,
+        ));
+    }
+    if p.old_str.len() > MAX_INPUT_BYTES || p.new_str.len() > MAX_INPUT_BYTES {
+        return Err(ErrorData::invalid_params(
+            format!("old_str/new_str exceeds {} byte limit", MAX_INPUT_BYTES),
+            None,
+        ));
+    }
+
+    let workspace_root = match p.workdir.as_deref() {
+        Some(w) => PathBuf::from(w),
+        None => state.cwd.clone(),
+    };
+    let target = match resolve_within(&workspace_root, &p.path) {
+        Ok(t) => t,
+        Err(e) => return Err(ErrorData::invalid_params(e, None)),
+    };
+
+    let meta = match std::fs::metadata(&target) {
+        Ok(m) => m,
+        Err(e) => {
+            return Err(ErrorData::internal_error(
+                format!("cannot stat {}: {e}", target.display()),
+                None,
+            ));
+        }
+    };
+    if !meta.is_file() {
+        return Err(ErrorData::invalid_params(
+            format!("not a regular file: {}", target.display()),
+            None,
+        ));
+    }
+    if meta.len() > MAX_FILE_BYTES {
+        return Err(ErrorData::invalid_params(
+            format!(
+                "file too large: {} is {} bytes (limit {} bytes)",
+                target.display(),
+                meta.len(),
+                MAX_FILE_BYTES
+            ),
+            None,
+        ));
+    }
+
+    let file = match std::fs::File::open(&target) {
+        Ok(f) => f,
+        Err(e) => {
+            return Err(ErrorData::internal_error(
+                format!("cannot open {}: {e}", target.display()),
+                None,
+            ));
+        }
+    };
+    let mut buf = Vec::with_capacity(meta.len() as usize);
+    use std::io::Read;
+    match file.take(MAX_FILE_BYTES + 1).read_to_end(&mut buf) {
+        Ok(n) if n as u64 > MAX_FILE_BYTES => {
+            return Err(ErrorData::invalid_params(
+                format!("file grew past {} bytes during read", MAX_FILE_BYTES),
+                None,
+            ));
+        }
+        Ok(_) => {}
+        Err(e) => {
+            return Err(ErrorData::internal_error(
+                format!("cannot read {}: {e}", target.display()),
+                None,
+            ));
+        }
+    }
+    let content = match String::from_utf8(buf) {
+        Ok(s) => s,
+        Err(e) => {
+            return Err(ErrorData::internal_error(
+                format!("not valid UTF-8: {}: {e}", target.display()),
+                None,
+            ));
+        }
+    };
+
+    let count = count_occurrences_capped(&content, &p.old_str);
+    match count {
+        0 => {
+            let hint = nearest_line_hint(&content, &p.old_str)
+                .map(|h| format!("\n{h}"))
+                .unwrap_or_default();
+            Err(ErrorData::invalid_params(
+                format!(
+                    "old_str not found in {}.\nold_str (truncated): {:?}{hint}",
+                    target.display(),
+                    truncate(&p.old_str, 80)
+                ),
+                None,
+            ))
+        }
+        1 => {
+            let new_content = content.replacen(&p.old_str, &p.new_str, 1);
+            if new_content.len() as u64 > MAX_FILE_BYTES {
+                return Err(ErrorData::invalid_params(
+                    format!(
+                        "result would exceed {} byte limit ({} bytes)",
+                        MAX_FILE_BYTES,
+                        new_content.len()
+                    ),
+                    None,
+                ));
+            }
+            if let Err(e) = atomic_write(&target, &new_content) {
+                return Err(ErrorData::internal_error(
+                    format!("failed to write {}: {e}", target.display()),
+                    None,
+                ));
+            }
+            let diff = unified_diff(&content, &new_content, &target);
+            Ok(format!(
+                "Replaced 1 occurrence in {}.\n\n{diff}",
+                target.display()
+            ))
+        }
+        _ => Err(ErrorData::invalid_params(
+            format!(
+                "old_str matched multiple locations in {}; provide more surrounding context to make the match unique.",
+                target.display()
+            ),
+            None,
+        )),
+    }
+}
+
+pub(crate) fn resolve_within(root: &Path, path: &str) -> Result<PathBuf, String> {
+    let raw = Path::new(path);
+    let candidate: PathBuf = if raw.is_absolute() {
+        raw.to_path_buf()
+    } else {
+        root.join(raw)
+    };
+
+    let root_canon = std::fs::canonicalize(root)
+        .map_err(|e| format!("workdir not accessible: {} ({e})", root.display()))?;
+
+    let resolved = std::fs::canonicalize(&candidate)
+        .map_err(|e| format!("path not accessible: {} ({e})", candidate.display()))?;
+
+    if !resolved.starts_with(&root_canon) {
+        return Err(format!(
+            "path escapes workspace: {} not within {}",
+            resolved.display(),
+            root_canon.display()
+        ));
+    }
+    Ok(resolved)
+}
+
+pub(crate) fn count_occurrences_capped(text: &str, pattern: &str) -> usize {
+    if pattern.is_empty() {
+        return 0;
+    }
+    let mut count = 0;
+    let mut start = 0;
+    while let Some(pos) = text[start..].find(pattern) {
+        count += 1;
+        if count >= 2 {
+            return count;
+        }
+        start += pos + pattern.len();
+    }
+    count
+}
+
+fn atomic_write(target: &Path, content: &str) -> std::io::Result<()> {
+    let parent = target.parent().unwrap_or_else(|| Path::new("."));
+    // Preserve original permissions so the atomic rename doesn't drop the file's mode.
+    let original_perms = std::fs::metadata(target).ok().map(|m| m.permissions());
+
+    let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
+    tmp.write_all(content.as_bytes())?;
+    tmp.flush()?;
+    tmp.persist(target).map_err(|e| e.error)?;
+
+    if let Some(perms) = original_perms {
+        let _ = std::fs::set_permissions(target, perms);
+    }
+    Ok(())
+}
+
+const MAX_DIFF_BYTES: usize = 64 * 1024;
+
+fn unified_diff(old: &str, new: &str, path: &Path) -> String {
+    let diff = TextDiff::from_lines(old, new);
+    let display = path.display();
+    let mut out = format!("--- a/{display}\n+++ b/{display}\n");
+    for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
+        let h = hunk.to_string();
+        if out.len() + h.len() > MAX_DIFF_BYTES {
+            out.push_str("\n[diff truncated]\n");
+            break;
+        }
+        out.push_str(&h);
+    }
+    out
+}
+
+fn truncate(s: &str, max_chars: usize) -> String {
+    if s.chars().count() <= max_chars {
+        s.to_string()
+    } else {
+        let head: String = s.chars().take(max_chars).collect();
+        format!("{head}…")
+    }
+}
+
+fn truncate_str(s: &str, max: usize) -> &str {
+    if s.len() <= max {
+        return s;
+    }
+    let mut cut = max;
+    while cut > 0 && !s.is_char_boundary(cut) {
+        cut -= 1;
+    }
+    &s[..cut]
+}
+
+fn similarity(a: &str, b: &str) -> f64 {
+    if a == b {
+        return 1.0;
+    }
+    if a.is_empty() || b.is_empty() {
+        return 0.0;
+    }
+    const MAX: usize = 512;
+    let a = truncate_str(a, MAX);
+    let b = truncate_str(b, MAX);
+    let matched: usize = TextDiff::from_chars(a, b)
+        .ops()
+        .iter()
+        .filter(|op| matches!(op.tag(), DiffTag::Equal))
+        .map(|op| op.new_range().len())
+        .sum();
+    matched as f64 / a.len().max(b.len()) as f64
+}
+
+fn nearest_line_hint(content: &str, pattern: &str) -> Option<String> {
+    let first = pattern.lines().next()?.trim();
+    if first.is_empty() {
+        return None;
+    }
+    let best = content
+        .lines()
+        .take(HINT_SCAN_LINE_LIMIT)
+        .enumerate()
+        .map(|(i, line)| (i, similarity(line.trim(), first), line))
+        .filter(|(_, s, _)| *s > 0.6)
+        .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))?;
+    Some(format!(
+        "Hint: nearest match around line {} (similarity {:.2}):\n  found:    {:?}\n  expected: {:?}",
+        best.0 + 1,
+        best.1,
+        best.2.trim(),
+        first
+    ))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::fs;
+    use tempfile::tempdir;
+
+    #[test]
+    fn count_occurrences_capped_returns_0_1_2() {
+        assert_eq!(count_occurrences_capped("hello world", "x"), 0);
+        assert_eq!(count_occurrences_capped("hello world", "hello"), 1);
+        assert_eq!(count_occurrences_capped("a a a a a", "a"), 2); // capped at 2
+        assert_eq!(count_occurrences_capped("abc", ""), 0);
+    }
+
+    #[test]
+    fn resolve_within_rejects_escape() {
+        let dir = tempdir().expect("tempdir");
+        let inside = dir.path().join("file.txt");
+        fs::write(&inside, b"x").expect("write");
+        // Symlink targeting outside the dir should be rejected.
+        #[cfg(unix)]
+        {
+            let outside = std::env::temp_dir().join("sprout-mcp-escape-target");
+            let _ = fs::remove_file(&outside);
+            fs::write(&outside, b"y").expect("write outside");
+            let link = dir.path().join("link.txt");
+            std::os::unix::fs::symlink(&outside, &link).expect("symlink");
+            let err = resolve_within(dir.path(), "link.txt").unwrap_err();
+            assert!(err.contains("escapes workspace"), "got: {err}");
+            let _ = fs::remove_file(&outside);
+        }
+        // Resolves a normal path inside.
+        let p = resolve_within(dir.path(), "file.txt").expect("resolve");
+        assert!(p.ends_with("file.txt"));
+    }
+
+    fn make_state(cwd: &std::path::Path) -> SharedState {
+        let shim = crate::shim::Shim::install().expect("shim install");
+        SharedState::new(cwd.to_path_buf(), shim).expect("state new")
+    }
+
+    #[test]
+    fn run_basic_replace_emits_diff() {
+        let dir = tempdir().expect("tempdir");
+        let f = dir.path().join("a.txt");
+        fs::write(&f, "alpha\nbeta\ngamma\n").expect("write");
+        let state = make_state(dir.path());
+        let p = StrReplaceParams {
+            path: "a.txt".into(),
+            old_str: "beta".into(),
+            new_str: "BETA".into(),
+            workdir: Some(dir.path().display().to_string()),
+        };
+        let out = run(&state, p).expect("ok");
+        assert!(out.contains("Replaced 1 occurrence"), "out: {out}");
+        assert!(out.contains("-beta"), "out: {out}");
+        assert!(out.contains("+BETA"), "out: {out}");
+        let contents = fs::read_to_string(&f).expect("read");
+        assert_eq!(contents, "alpha\nBETA\ngamma\n");
+    }
+
+    #[test]
+    fn run_rejects_path_outside_workspace() {
+        let dir = tempdir().expect("tempdir");
+        let state = make_state(dir.path());
+        let p = StrReplaceParams {
+            path: "/etc/hosts".into(),
+            old_str: "x".into(),
+            new_str: "y".into(),
+            workdir: Some(dir.path().display().to_string()),
+        };
+        let err = run(&state, p).unwrap_err();
+        let msg = format!("{err:?}");
+        assert!(
+            msg.contains("escapes workspace") || msg.contains("not accessible"),
+            "msg: {msg}"
+        );
+    }
+
+    #[test]
+    fn run_rejects_file_too_large() {
+        let dir = tempdir().expect("tempdir");
+        let f = dir.path().join("big.bin");
+        let big = vec![b'a'; (MAX_FILE_BYTES as usize) + 1024];
+        fs::write(&f, &big).expect("write");
+        let state = make_state(dir.path());
+        let p = StrReplaceParams {
+            path: "big.bin".into(),
+            old_str: "a".into(),
+            new_str: "b".into(),
+            workdir: Some(dir.path().display().to_string()),
+        };
+        let err = run(&state, p).unwrap_err();
+        let msg = format!("{err:?}");
+        assert!(msg.contains("too large"), "msg: {msg}");
+    }
+}
diff --git a/crates/sprout-dev-mcp/src/todo.rs b/crates/sprout-dev-mcp/src/todo.rs
new file mode 100644
index 0000000000..de218356f2
--- /dev/null
+++ b/crates/sprout-dev-mcp/src/todo.rs
@@ -0,0 +1,558 @@
+//! In-memory session task list with `_Stop` and `_PostCompact` hooks.
+//!
+//! - `todo`: read or full-list-replace the task list. Empty args reads.
+//! - `_Stop`: hook called by the agent before honoring end_turn. Returns
+//!   objection text if any items are open, empty otherwise.
+//! - `_PostCompact`: hook called after context compaction/handoff.
+//!   Returns the full list state so the agent can re-inject it.
+//!
+//! State is per-process (Vec<Item> behind a Mutex). Items are
+//! `{text, done}` — no ids; the LLM provides a full replacement list.
+//! On replacement, open items that disappear without being marked done
+//! trigger a soft warning appended to the tool response.
+
+use rmcp::model::{CallToolResult, Content};
+use rmcp::ErrorData;
+use schemars::JsonSchema;
+use serde::Deserialize;
+use std::sync::Mutex;
+
+const MAX_ITEMS: usize = 50;
+const MAX_TEXT_CHARS: usize = 200;
+
+#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct Item {
+    #[schemars(length(min = 1, max = 200))]
+    pub text: String,
+    #[serde(default)]
+    pub done: bool,
+}
+
+#[derive(Debug, Deserialize, JsonSchema)]
+#[serde(deny_unknown_fields)]
+pub struct TodoParams {
+    /// Full replacement list (max 50 items). Omit to read.
+    // Both omitted and explicit null mean "read current state".
+    #[serde(default)]
+    #[schemars(length(max = 50))]
+    pub todos: Option<Vec<Item>>,
+}
+
+/// Empty params struct for the hook tools. Hooks take no arguments but
+/// rmcp requires Parameters<T> for the macro.
+#[derive(Debug, Deserialize, JsonSchema, Default)]
+#[serde(deny_unknown_fields)]
+pub struct HookParams {}
+
+#[derive(Debug, Default)]
+pub struct TodoState {
+    items: Mutex<Vec<Item>>,
+}
+
+impl TodoState {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    fn with_items<R>(&self, f: impl FnOnce(&mut Vec<Item>) -> R) -> R {
+        let mut g = match self.items.lock() {
+            Ok(g) => g,
+            Err(p) => p.into_inner(),
+        };
+        f(&mut g)
+    }
+
+    /// Replace-or-read. Returns the rendered list, with a warning
+    /// appended if open items were silently removed. Mutation, warning
+    /// computation, and rendering all occur under a single lock hold so
+    /// that the rendered output reflects exactly the list that was just
+    /// written (no interleaving with concurrent calls).
+    pub fn handle_todo(&self, params: TodoParams) -> Result<String, String> {
+        if let Some(mut new_items) = params.todos {
+            validate(&new_items)?;
+            // Normalize: trim text on storage so duplicate detection
+            // and silent-removal diff operate on the same canonical
+            // form. Validation already rejected empty-after-trim text,
+            // duplicates after trim, and invalid characters.
+            for it in &mut new_items {
+                it.text = it.text.trim().to_owned();
+            }
+            return Ok(self.with_items(|items| {
+                let warning = silent_removal_warning(items, &new_items);
+                *items = new_items;
+                let mut out = render_items(items);
+                if !warning.is_empty() {
+                    out.push('\n');
+                    out.push_str(&warning);
+                }
+                out
+            }));
+        }
+        Ok(self.render())
+    }
+
+    pub fn render(&self) -> String {
+        self.with_items(|items| render_items(items))
+    }
+
+    /// Objection text if open items exist, empty string otherwise.
+    pub fn stop_objection(&self) -> String {
+        self.with_items(|items| {
+            if items.iter().any(|i| !i.done) {
+                format!(
+                    "You have open todo items. Keep working.\n\n{}",
+                    render_items(items)
+                )
+            } else {
+                String::new()
+            }
+        })
+    }
+
+    /// Re-injection block for after a handoff. Empty if no items.
+    pub fn post_compact(&self) -> String {
+        self.with_items(|items| {
+            if items.is_empty() {
+                String::new()
+            } else {
+                format!("# Todo List\n{}", render_items(items))
+            }
+        })
+    }
+}
+
+/// Reject characters that aren't safe for single-line display. ASCII
+/// space is the only whitespace allowed; everything else (control
+/// chars, exotic whitespace, bidi/format chars, zero-width joiners,
+/// BOM) could be used to spoof the rendered todo output.
+fn invalid_text_char(c: char) -> bool {
+    if c.is_control() {
+        return true;
+    }
+    if c.is_whitespace() && c != ' ' {
+        // Catches \t, \n, \r, NBSP (\u{00A0}), line/paragraph
+        // separators (\u{2028}, \u{2029}), and other exotic spaces.
+        return true;
+    }
+    matches!(c,
+        '\u{200B}'..='\u{200F}' // zero-width spaces, LRM/RLM
+        | '\u{202A}'..='\u{202E}' // bidi embedding/override
+        | '\u{2060}'..='\u{206F}' // word joiner, invisible operators
+        | '\u{FEFF}' // BOM / zero-width no-break space
+    )
+}
+
+fn validate(items: &[Item]) -> Result<(), String> {
+    if items.len() > MAX_ITEMS {
+        return Err(format!("too many items (max {MAX_ITEMS})"));
+    }
+    for (i, it) in items.iter().enumerate() {
+        if it.text.trim().is_empty() {
+            return Err(format!("item {}: text is empty", i + 1));
+        }
+        if it.text.trim().chars().count() > MAX_TEXT_CHARS {
+            return Err(format!(
+                "item {}: text exceeds {MAX_TEXT_CHARS} characters",
+                i + 1
+            ));
+        }
+        // Reject control characters and Unicode trickery that could
+        // spoof the rendered output: line/paragraph separators, bidi
+        // overrides, zero-width joiners, BOM, etc. See
+        // `invalid_text_char` for the full list.
+        if it.text.chars().any(invalid_text_char) {
+            return Err(format!("item {}: text contains invalid characters", i + 1));
+        }
+    }
+    // Reject duplicates (after trim). Without ids, duplicates are
+    // ambiguous: silent-removal detection can't tell which copy was
+    // dropped, and they have no semantic meaning anyway.
+    for (i, a) in items.iter().enumerate() {
+        let a_trim = a.text.trim();
+        for (j, b) in items.iter().enumerate().skip(i + 1) {
+            if a_trim == b.text.trim() {
+                return Err(format!(
+                    "item {}: duplicate text (matches item {})",
+                    j + 1,
+                    i + 1
+                ));
+            }
+        }
+    }
+    Ok(())
+}
+
+/// Diff old vs new. Any open (`!done`) item in `old` whose `text` is
+/// absent from `new` (any position, any done-state) is "silently
+/// removed". Returns the warning string, or empty if none.
+///
+/// `validate()` rejects duplicate text in `new`, so a `HashSet` lookup
+/// is unambiguous here.
+fn silent_removal_warning(old: &[Item], new: &[Item]) -> String {
+    let new_texts: std::collections::HashSet<&str> = new.iter().map(|i| i.text.as_str()).collect();
+    let removed: Vec<&str> = old
+        .iter()
+        .filter(|i| !i.done && !new_texts.contains(i.text.as_str()))
+        .map(|i| i.text.as_str())
+        .collect();
+    if removed.is_empty() {
+        return String::new();
+    }
+    let mut out = format!(
+        "⚠️ {} open item{} {} removed from the list:\n",
+        removed.len(),
+        if removed.len() == 1 { "" } else { "s" },
+        if removed.len() == 1 { "was" } else { "were" },
+    );
+    for t in &removed {
+        out.push_str("  - \"");
+        out.push_str(t);
+        out.push_str("\"\n");
+    }
+    out.push_str(
+        "If accidental, re-add them. If they are complete, prefer marking items done before removing.",
+    );
+    out
+}
+
+fn render_items(items: &[Item]) -> String {
+    if items.is_empty() {
+        return "(todo list is empty)".into();
+    }
+    let next = items.iter().position(|i| !i.done);
+    let mut out = String::with_capacity(64 * items.len());
+    for (i, it) in items.iter().enumerate() {
+        let box_ = if it.done { "[x]" } else { "[ ]" };
+        out.push_str(box_);
+        out.push(' ');
+        out.push_str(&(i + 1).to_string());
+        out.push_str(". ");
+        out.push_str(&it.text);
+        if Some(i) == next {
+            out.push_str("  ← next");
+        }
+        out.push('\n');
+    }
+    out
+}
+
+/// Wrap a string result as an MCP CallToolResult with text content.
+pub fn text_result(s: String) -> Result<CallToolResult, ErrorData> {
+    Ok(CallToolResult::success(vec![Content::text(s)]))
+}
+
+/// Wrap an error string as an MCP CallToolResult with isError=true.
+pub fn error_result(s: String) -> Result<CallToolResult, ErrorData> {
+    Ok(CallToolResult::error(vec![Content::text(s)]))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn mk(items: &[(&str, bool)]) -> Vec<Item> {
+        items
+            .iter()
+            .map(|(text, done)| Item {
+                text: (*text).to_owned(),
+                done: *done,
+            })
+            .collect()
+    }
+
+    fn write(s: &TodoState, items: &[(&str, bool)]) -> String {
+        s.handle_todo(TodoParams {
+            todos: Some(mk(items)),
+        })
+        .unwrap()
+    }
+
+    #[test]
+    fn empty_read_returns_placeholder() {
+        let s = TodoState::new();
+        let out = s.handle_todo(TodoParams { todos: None }).unwrap();
+        assert!(out.contains("empty"));
+    }
+
+    #[test]
+    fn rejects_empty_text() {
+        let s = TodoState::new();
+        let err = s
+            .handle_todo(TodoParams {
+                todos: Some(mk(&[("   ", false)])),
+            })
+            .unwrap_err();
+        assert!(err.contains("text is empty"));
+    }
+
+    #[test]
+    fn rejects_too_many_items() {
+        let s = TodoState::new();
+        let many: Vec<Item> = (0..=MAX_ITEMS)
+            .map(|i| Item {
+                text: format!("item {i}"),
+                done: false,
+            })
+            .collect();
+        let err = s.handle_todo(TodoParams { todos: Some(many) }).unwrap_err();
+        assert!(err.contains("too many items"));
+    }
+
+    #[test]
+    fn rejects_duplicate_text() {
+        let s = TodoState::new();
+        let err = s
+            .handle_todo(TodoParams {
+                todos: Some(mk(&[("same", false), ("same", true)])),
+            })
+            .unwrap_err();
+        assert!(err.contains("duplicate"), "got: {err}");
+    }
+
+    #[test]
+    fn rejects_duplicate_text_after_trim() {
+        let s = TodoState::new();
+        let err = s
+            .handle_todo(TodoParams {
+                todos: Some(mk(&[("foo", false), ("  foo  ", false)])),
+            })
+            .unwrap_err();
+        assert!(err.contains("duplicate"), "got: {err}");
+    }
+
+    #[test]
+    fn rejects_control_characters() {
+        let s = TodoState::new();
+        let err_n = s
+            .handle_todo(TodoParams {
+                todos: Some(mk(&[("line1\nline2", false)])),
+            })
+            .unwrap_err();
+        assert!(err_n.contains("invalid characters"), "got: {err_n}");
+        let err_t = s
+            .handle_todo(TodoParams {
+                todos: Some(mk(&[("col1\tcol2", false)])),
+            })
+            .unwrap_err();
+        assert!(err_t.contains("invalid characters"), "got: {err_t}");
+    }
+
+    #[test]
+    fn rejects_unicode_line_separator() {
+        let s = TodoState::new();
+        let err = s
+            .handle_todo(TodoParams {
+                todos: Some(mk(&[("line1\u{2028}line2", false)])),
+            })
+            .unwrap_err();
+        assert!(err.contains("invalid characters"), "got: {err}");
+    }
+
+    #[test]
+    fn rejects_bidi_override() {
+        let s = TodoState::new();
+        let err = s
+            .handle_todo(TodoParams {
+                todos: Some(mk(&[("text\u{202E}reversed", false)])),
+            })
+            .unwrap_err();
+        assert!(err.contains("invalid characters"), "got: {err}");
+    }
+
+    #[test]
+    fn rejects_zero_width() {
+        let s = TodoState::new();
+        let err = s
+            .handle_todo(TodoParams {
+                todos: Some(mk(&[("zero\u{200B}width", false)])),
+            })
+            .unwrap_err();
+        assert!(err.contains("invalid characters"), "got: {err}");
+    }
+
+    #[test]
+    fn leading_trailing_spaces_trimmed_on_store() {
+        let s = TodoState::new();
+        // Send with surrounding spaces.
+        let out = write(&s, &[("  task  ", false)]);
+        // Render should have the trimmed form; no leading/trailing
+        // spaces in the displayed text.
+        assert!(out.contains("[ ] 1. task"));
+        assert!(!out.contains("  task  "));
+        // A subsequent write of the trimmed form should not falsely
+        // warn that "  task  " was silently removed: stored text is
+        // already "task", so the diff sees no removal.
+        let out2 = write(&s, &[("task", false)]);
+        assert!(!out2.contains("⚠️"), "unexpected warning: {out2}");
+    }
+
+    #[test]
+    fn hook_params_rejects_unknown_fields() {
+        let res: Result<HookParams, _> = serde_json::from_str(r#"{"x":1}"#);
+        assert!(res.is_err(), "expected error, got: {res:?}");
+        // Sanity: empty object parses.
+        let ok: Result<HookParams, _> = serde_json::from_str("{}");
+        assert!(ok.is_ok(), "expected ok, got: {ok:?}");
+    }
+
+    #[test]
+    fn explicit_null_is_read() {
+        let s = TodoState::new();
+        write(&s, &[("alpha", false)]);
+        // {"todos": null} should behave the same as omitting the field
+        // — return the current list, don't clear it.
+        let params: TodoParams = serde_json::from_str(r#"{"todos":null}"#).unwrap();
+        let out = s.handle_todo(params).unwrap();
+        assert!(out.contains("[ ] 1. alpha"), "got: {out}");
+        // Verify state was not mutated.
+        let again = s.render();
+        assert!(again.contains("[ ] 1. alpha"));
+    }
+
+    #[test]
+    fn rejects_unknown_fields() {
+        // Unknown field on Item: `complete` instead of `done`.
+        let bad_item = r#"{"todos":[{"text":"x","complete":true}]}"#;
+        let res: Result<TodoParams, _> = serde_json::from_str(bad_item);
+        assert!(res.is_err(), "expected error, got: {res:?}");
+        // Unknown field on TodoParams.
+        let bad_root = r#"{"todos":[{"text":"x"}],"extra":1}"#;
+        let res: Result<TodoParams, _> = serde_json::from_str(bad_root);
+        assert!(res.is_err(), "expected error, got: {res:?}");
+        // Sanity: the correct shape parses.
+        let good = r#"{"todos":[{"text":"x","done":true}]}"#;
+        let res: Result<TodoParams, _> = serde_json::from_str(good);
+        assert!(res.is_ok(), "expected ok, got: {res:?}");
+    }
+
+    #[test]
+    fn text_at_boundary_200_chars() {
+        let s = TodoState::new();
+        let exactly_200 = "a".repeat(200);
+        let out = s
+            .handle_todo(TodoParams {
+                todos: Some(mk(&[(exactly_200.as_str(), false)])),
+            })
+            .unwrap();
+        assert!(out.contains(&exactly_200));
+        let too_long = "b".repeat(201);
+        let err = s
+            .handle_todo(TodoParams {
+                todos: Some(mk(&[(too_long.as_str(), false)])),
+            })
+            .unwrap_err();
+        assert!(err.contains("exceeds"), "got: {err}");
+    }
+
+    #[test]
+    fn write_and_render_atomic() {
+        // The string returned from a write must reflect the list that
+        // was just written, not stale state. With the atomic
+        // implementation the response IS computed under the same lock
+        // hold as the mutation, so this is guaranteed structurally; we
+        // assert the contract.
+        let s = TodoState::new();
+        let out = write(&s, &[("alpha", false), ("beta", true)]);
+        assert!(out.contains("[ ] 1. alpha"));
+        assert!(out.contains("[x] 2. beta"));
+        // And a subsequent render returns the same content.
+        let again = s.render();
+        assert!(again.contains("[ ] 1. alpha"));
+        assert!(again.contains("[x] 2. beta"));
+    }
+
+    #[test]
+    fn render_uses_position_numbers() {
+        let s = TodoState::new();
+        let out = write(&s, &[("first", true), ("second", false), ("third", false)]);
+        assert!(out.contains("[x] 1. first"));
+        assert!(out.contains("[ ] 2. second"));
+        assert!(out.contains("[ ] 3. third"));
+        // ← next on first open item
+        assert!(out.contains("[ ] 2. second  ← next"));
+        assert!(!out.contains("[ ] 3. third  ← next"));
+    }
+
+    #[test]
+    fn stop_returns_objection_when_open_items_exist() {
+        let s = TodoState::new();
+        write(&s, &[("a", false), ("b", false)]);
+        let obj = s.stop_objection();
+        assert!(!obj.is_empty(), "expected non-empty objection");
+        assert!(obj.contains("open todo items"));
+        assert!(obj.contains("a"));
+    }
+
+    #[test]
+    fn stop_returns_empty_when_all_done() {
+        let s = TodoState::new();
+        write(&s, &[("a", true), ("b", true)]);
+        assert_eq!(s.stop_objection(), "");
+    }
+
+    #[test]
+    fn stop_returns_empty_when_list_is_empty() {
+        assert_eq!(TodoState::new().stop_objection(), "");
+    }
+
+    #[test]
+    fn post_compact_renders_when_populated() {
+        let s = TodoState::new();
+        write(&s, &[("a", false)]);
+        let block = s.post_compact();
+        assert!(block.starts_with("# Todo List\n"));
+    }
+
+    #[test]
+    fn post_compact_empty_when_no_items() {
+        assert_eq!(TodoState::new().post_compact(), "");
+    }
+
+    #[test]
+    fn silent_removal_warns() {
+        let s = TodoState::new();
+        write(
+            &s,
+            &[
+                ("fix the flaky test", false),
+                ("write unit tests", false),
+                ("ship it", false),
+            ],
+        );
+        let out = write(&s, &[("ship it", false)]);
+        assert!(out.contains("⚠️"), "expected warning, got: {out}");
+        assert!(out.contains("2 open items"));
+        assert!(out.contains("\"fix the flaky test\""));
+        assert!(out.contains("\"write unit tests\""));
+    }
+
+    #[test]
+    fn removal_of_done_item_no_warning() {
+        let s = TodoState::new();
+        write(&s, &[("done thing", true), ("open thing", false)]);
+        let out = write(&s, &[("open thing", false)]);
+        assert!(!out.contains("⚠️"), "unexpected warning: {out}");
+    }
+
+    #[test]
+    fn rename_triggers_warning() {
+        let s = TodoState::new();
+        write(&s, &[("write tests", false)]);
+        let out = write(&s, &[("write unit tests", false)]);
+        assert!(out.contains("⚠️"), "expected warning, got: {out}");
+        assert!(out.contains("\"write tests\""));
+    }
+
+    #[test]
+    fn marking_done_then_removing_no_warning() {
+        let s = TodoState::new();
+        write(&s, &[("task", false)]);
+        // Mark done.
+        let out1 = write(&s, &[("task", true)]);
+        assert!(!out1.contains("⚠️"));
+        // Now remove (it's done in the prior list).
+        let out2 = write(&s, &[]);
+        assert!(!out2.contains("⚠️"), "unexpected warning: {out2}");
+    }
+}
diff --git a/crates/sprout-dev-mcp/src/tree.rs b/crates/sprout-dev-mcp/src/tree.rs
new file mode 100644
index 0000000000..cfa114de7b
--- /dev/null
+++ b/crates/sprout-dev-mcp/src/tree.rs
@@ -0,0 +1,184 @@
+use std::io::Write;
+use std::path::{Path, PathBuf};
+
+use ignore::WalkBuilder;
+
+const MAX_OUTPUT_BYTES: usize = 50 * 1024;
+const MAX_OUTPUT_LINES: usize = 2000;
+const MAX_WALK_DEPTH: usize = 50;
+const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
+
+struct Frame {
+    depth: usize,
+    out_idx: usize,
+    total: usize,
+    leaf: bool,
+}
+
+pub fn run(args: Vec<String>) -> i32 {
+    let (root, max_depth) = match parse(args) {
+        Ok(v) => v,
+        Err(e) => {
+            tracing::error!("tree: {e}");
+            return 2;
+        }
+    };
+    if !root.is_dir() {
+        tracing::error!("tree: not a directory: {}", root.display());
+        return 2;
+    }
+
+    let mut out: Vec<String> = Vec::new();
+    let mut stack: Vec<Frame> = vec![Frame {
+        depth: 0,
+        out_idx: 0,
+        total: 0,
+        leaf: false,
+    }];
+    let line_budget = MAX_OUTPUT_LINES.saturating_sub(1);
+    let mut truncated = false;
+
+    let mut builder = WalkBuilder::new(&root);
+    builder.git_ignore(true);
+    builder.git_exclude(true);
+    builder.git_global(true);
+    builder.require_git(false);
+    builder.ignore(true);
+    builder.hidden(true);
+    builder.max_depth(Some(max_depth));
+    builder.sort_by_file_name(|a, b| a.cmp(b));
+
+    for entry in builder.build().flatten() {
+        if entry.depth() == 0 {
+            continue;
+        }
+        let depth = entry.depth();
+        let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
+        let is_file = entry.file_type().is_some_and(|t| t.is_file());
+
+        while stack.last().is_some_and(|f| f.depth >= depth) {
+            let Some(frame) = stack.pop() else { break };
+            if let Some(parent) = stack.last_mut() {
+                parent.total = parent.total.saturating_add(frame.total);
+            }
+            if frame.depth > 0 && !frame.leaf {
+                let placeholder = &mut out[frame.out_idx];
+                *placeholder = format!("{}  [{}]", placeholder, frame.total);
+            }
+        }
+
+        if out.len() >= line_budget {
+            truncated = true;
+            break;
+        }
+
+        let prefix = "  ".repeat(depth - 1);
+        let name = entry.file_name().to_string_lossy();
+
+        if is_dir {
+            let idx = out.len();
+            out.push(format!("{prefix}{name}/"));
+            stack.push(Frame {
+                depth,
+                out_idx: idx,
+                total: 0,
+                leaf: depth == max_depth,
+            });
+        } else if is_file {
+            let lc = line_count(entry.path());
+            if let Some(parent) = stack.last_mut() {
+                parent.total = parent.total.saturating_add(lc);
+            }
+            out.push(format!("{prefix}{name}  [{lc}]"));
+        }
+    }
+
+    let mut grand_total = 0usize;
+    while let Some(frame) = stack.pop() {
+        if let Some(parent) = stack.last_mut() {
+            parent.total = parent.total.saturating_add(frame.total);
+        }
+        if frame.depth == 0 {
+            grand_total = frame.total;
+        } else if !frame.leaf {
+            let placeholder = &mut out[frame.out_idx];
+            *placeholder = format!("{}  [{}]", placeholder, frame.total);
+        }
+    }
+
+    let root_name = root
+        .file_name()
+        .unwrap_or(root.as_os_str())
+        .to_string_lossy();
+
+    let stdout = std::io::stdout();
+    let mut w = stdout.lock();
+    if writeln!(w, "{root_name}/  [{grand_total}]").is_err() {
+        return 0;
+    }
+
+    let mut bytes = 0usize;
+    for line in &out {
+        if bytes + line.len() + 1 > MAX_OUTPUT_BYTES {
+            let _ = writeln!(w, "[truncated]");
+            return 0;
+        }
+        if writeln!(w, "{line}").is_err() {
+            return 0;
+        }
+        bytes += line.len() + 1;
+    }
+    if truncated {
+        let _ = writeln!(w, "[truncated]");
+    }
+    0
+}
+
+fn parse(args: Vec<String>) -> Result<(PathBuf, usize), String> {
+    let mut depth = MAX_WALK_DEPTH;
+    let mut path = PathBuf::from(".");
+    let mut path_set = false;
+    let mut iter = args.into_iter();
+    while let Some(a) = iter.next() {
+        match a.as_str() {
+            "--" => {
+                if let Some(p) = iter.next() {
+                    if path_set {
+                        return Err("multiple paths not supported".to_string());
+                    }
+                    path = PathBuf::from(p);
+                }
+                break;
+            }
+            "-d" | "--depth" => {
+                let n = iter.next().ok_or("missing value for --depth")?;
+                depth = n.parse::<usize>().map_err(|_| format!("bad depth: {n}"))?;
+            }
+            s if s.starts_with('-') => return Err(format!("unknown flag: {s}")),
+            _ => {
+                if path_set {
+                    return Err("multiple paths not supported".to_string());
+                }
+                path = PathBuf::from(a);
+                path_set = true;
+            }
+        }
+    }
+    Ok((path, depth.min(MAX_WALK_DEPTH)))
+}
+
+fn line_count(path: &Path) -> usize {
+    std::fs::metadata(path)
+        .ok()
+        .filter(|m| m.is_file() && m.len() <= MAX_FILE_BYTES)
+        .and_then(|_| std::fs::read(path).ok())
+        .map(|b| {
+            if b.is_empty() {
+                0
+            } else {
+                b.iter().filter(|&&c| c == b'\n').count()
+                    + if b.last() != Some(&b'\n') { 1 } else { 0 }
+            }
+        })
+        .unwrap_or(0)
+}
diff --git a/docs/MCP_DRIVEN_HOOKS.md b/docs/MCP_DRIVEN_HOOKS.md
new file mode 100644
index 0000000000..b4faabeafc
--- /dev/null
+++ b/docs/MCP_DRIVEN_HOOKS.md
@@ -0,0 +1,110 @@
+# MCP-Driven Lifecycle Hooks
+
+## Overview
+
+Sprout-agent supports lifecycle hooks — MCP tools that the agent calls at
+defined points in its execution loop. Any MCP server can participate by
+exposing tools with the `_` prefix. Hooks are invisible to the LLM, advisory
+to the agent, and operator-configured.
+
+This convention requires zero MCP protocol changes. Hooks are regular tools
+discovered via `tools/list` and invoked via `tools/call`.
+
+## Convention
+
+- Tools whose bare name starts with `_` are lifecycle hooks
+- Hooks are filtered from the tool list sent to the LLM
+- Hooks are rejected if the LLM attempts to call them directly
+- Hooks are called by the agent at defined lifecycle points
+- Hook responses are injected as tool-result messages (lower trust than system)
+- Hook output is JSON-encoded for prompt-injection safety
+
+## Defined Hooks
+
+### `_Stop`
+
+**When:** The LLM signals `end_turn`, before the agent honors it.
+
+**Input:** `{}`
+
+**Output:** Non-empty text = objection (agent continues). Empty = no objection
+(agent stops).
+
+**Use case:** Todo enforcement — object when open tasks remain.
+
+### `_PostCompact`
+
+**When:** After context compaction/handoff, before the next LLM prompt.
+
+**Input:** `{}`
+
+**Output:** Non-empty text = injected into fresh context. Empty = nothing
+injected.
+
+**Use case:** Re-inject todo list state after history is summarized and reset.
+
+## Agent Sovereignty
+
+Hooks are advisory, not authoritative. The agent enforces:
+
+| Constraint | Behavior |
+|---|---|
+| Timeout (2.5s default) | Treated as no objection. Server killed only on second consecutive timeout (tolerates one-off slowness) |
+| Rejection budget (3/session) | After exhaustion, agent stops regardless |
+| Consecutive end_turn | If LLM ends again without tool calls after an objection, agent stops — the LLM heard and declined |
+
+These constraints ensure a buggy or malicious hook cannot trap the agent.
+
+## Configuration
+
+| Env Var | Default | Description |
+|---|---|---|
+| `MCP_HOOK_SERVERS` | (unset = no hooks) | Allowlist: `*` for all servers, or comma-separated names |
+| `SPROUT_AGENT_HOOK_TIMEOUT_MS` | 2500 | Per-hook call timeout in milliseconds |
+| `SPROUT_AGENT_STOP_MAX_REJECTIONS` | 3 | Session-wide `_Stop` budget (0 = disable) |
+
+Hooks are **off by default**. The operator must explicitly opt in via
+`MCP_HOOK_SERVERS`.
+
+## Implementing a Hook
+
+Any MCP server can expose hooks. Example: a test-runner server that blocks
+`end_turn` while tests are failing:
+
+```json
+{
+  "name": "_Stop",
+  "description": "Returns failing test summary if suite is red.",
+  "inputSchema": { "type": "object" }
+}
+```
+
+The server returns non-empty text to object, empty string to allow stopping.
+
+## Compatibility
+
+Hook naming is aligned with the [Open Plugin Spec](https://open-plugins.com/agent-builders/components/hooks)
+event conventions. `_Stop` corresponds to the `Stop` event; `_PostCompact`
+corresponds to `PostCompact`.
+
+`MCP_HOOK_SERVERS` is a standard env var name intended for cross-agent adoption.
+
+## Future Work
+
+Additional hook points may be added to support the fuller Open Plugin Spec
+event set:
+
+| Open Plugin Event | Potential Hook | Status |
+|---|---|---|
+| `Stop` | `_Stop` | ✅ Implemented |
+| `PostCompact` | `_PostCompact` | ✅ Implemented |
+| `PreToolUse` | `_PreToolUse` | Deferred (overlaps with MCP Interceptors SEP-2624) |
+| `PostToolUse` | `_PostToolUse` | Deferred (overlaps with MCP Interceptors SEP-2624) |
+| `SessionStart` | `_SessionStart` | Candidate for future revision |
+| `SessionEnd` | `_SessionEnd` | Candidate for future revision |
+| `UserPromptSubmit` | `_UserPromptSubmit` | Candidate for future revision |
+| `SubagentStart` | `_SubagentStart` | Candidate for future revision |
+
+Pre/post tool-call hooks are deferred pending coordination with the MCP
+Interceptors working group (SEP-2624), which addresses similar concerns at
+the protocol layer. The remaining events will be added as use cases emerge.
