Comparing changes

v0.9.2 v0.10.0
25 commits 52 files changed

Commits

481194e docs: update CHANGELOG and README mo khan 2026-01-28 17:58:46
e4afa5f refactor: freeze params mo khan 2026-01-28 15:28:01
01512b6 chore: log error mo khan 2026-01-28 15:27:52
a107d9d test: backfill tests mo khan 2026-01-28 05:09:36
8dc33be fix: pretty print MCP response data mo khan 2026-01-27 23:45:21
653a65c chore: log MCP responses mo khan 2026-01-27 22:06:48
ee69854 chore: bump to version 0.10.0 mo khan 2026-01-27 21:30:42
d8acc14 feat: format tool result with markdown mo khan 2026-01-27 21:30:16
3150668 docs: sort tools in README mo khan 2026-01-27 16:59:26
ab77b45 feat: add more tools mo khan 2026-01-26 22:41:37
.elelem/plugins/gitlab.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:gitlab) do |agent|
+  agent.toolbox.after("gitlab_search") do |_args, result|
+    IO.popen(["jq", "-C", "."], "r+") do |io|
+      io.write(result.to_json)
+      io.close_write
+      agent.terminal.say(io.read)
+    end
+  end
+end
.elelem/mcp.json
@@ -0,0 +1,14 @@
+{
+  "mcpServers": {
+    "gitlab": {
+      "type": "http",
+      "url": "https://gitlab.com/api/v4/mcp"
+    },
+    "playwright": {
+      "command": "npx",
+      "args": [
+        "@playwright/mcp@latest"
+      ]
+    }
+  }
+}
exe/elelem
@@ -2,84 +2,7 @@
 # frozen_string_literal: true
 
 require "elelem"
-require "optparse"
 
 Signal.trap("INT") { exit 1 }
 
-class App
-  MODELS = {
-    "ollama" => "gpt-oss:latest",
-    "anthropic" => "claude-opus-4-5-20250514",
-    "vertex" => "claude-opus-4-5@20251101",
-    "openai" => "gpt-4o"
-  }.freeze
-
-  PROVIDERS = {
-    "ollama" => ->(model) { Elelem::Net::Ollama.new(model: model, host: ENV.fetch("OLLAMA_HOST", "localhost:11434")) },
-    "anthropic" => ->(model) { Elelem::Net::Claude.anthropic(model: model, api_key: ENV.fetch("ANTHROPIC_API_KEY")) },
-    "vertex" => ->(model) { Elelem::Net::Claude.vertex(model: model, project: ENV.fetch("GOOGLE_CLOUD_PROJECT"), region: ENV.fetch("GOOGLE_CLOUD_REGION", "us-east5")) },
-    "openai" => ->(model) { Elelem::Net::OpenAI.new(model: model, api_key: ENV.fetch("OPENAI_API_KEY")) }
-  }.freeze
-
-  def initialize(args)
-    @provider = "ollama"
-    @model = nil
-    @args = parse(args)
-  end
-
-  def run
-    command = @args.shift || "chat"
-    send(command.tr("-", "_"))
-  rescue NoMethodError
-    abort "Unknown command: #{command}"
-  end
-
-  private
-
-  def parse(args)
-    @parser = OptionParser.new do |o|
-      o.banner = "Usage: elelem [command] [options] [args]"
-      o.separator "\nCommands:"
-      o.separator "  chat              Interactive REPL (default)"
-      o.separator "  ask <prompt>      One-shot query (reads stdin if piped)"
-      o.separator "  files             Output files as XML (no options)"
-      o.separator "  help              Show this help"
-      o.separator "\nOptions:"
-      o.on("-p", "--provider NAME", "ollama, anthropic, vertex, openai") { |p| @provider = p }
-      o.on("-m", "--model NAME", "Override default model") { |m| @model = m }
-      o.on("-h", "--help") { puts o; exit }
-    end
-    @parser.parse!(args)
-  end
-
-  def help
-    puts @parser
-  end
-
-  def client
-    model = @model || MODELS.fetch(@provider)
-    PROVIDERS.fetch(@provider).call(model)
-  end
-
-  def chat = Elelem.start(client)
-
-  def ask
-    abort "Usage: elelem ask <prompt>" if @args.empty?
-    prompt = @args.join(" ")
-    prompt = "#{prompt}\n\n```\n#{$stdin.read}\n```" if $stdin.stat.pipe?
-    Elelem::Terminal.new.markdown Elelem.ask(client, prompt)
-  end
-
-  def files
-    files = $stdin.stat.pipe? ? $stdin.readlines : `git ls-files`.lines
-    puts "<documents>"
-    files.each_with_index do |line, i|
-      path = line.strip
-      next if path.empty? || !File.file?(path)
-      puts %Q{<document index="#{i + 1}"><source>#{path}</source><content><![CDATA[#{File.read(path)}]]></content></document>}
-    end
-    puts "</documents>"
-  end
-end
-
-App.new(ARGV).run
+Elelem::CLI.new(ARGV).run
lib/elelem/mcp/oauth.rb
@@ -0,0 +1,217 @@
+# frozen_string_literal: true
+
+module Elelem
+  class MCP
+    class OAuth
+      CALLBACK_PORT = 18273
+      REDIRECT_URI = "http://127.0.0.1:#{CALLBACK_PORT}/callback"
+
+      def initialize(resource_url, http: Elelem::Net.http)
+        @resource_url = resource_url
+        @http = http
+        @storage = TokenStorage.new
+      end
+
+      def token
+        stored = @storage.load(@resource_url)
+        return stored[:access_token] if stored && !expired?(stored)
+        return refresh(stored[:refresh_token]) if stored&.dig(:refresh_token)
+
+        authorize
+      end
+
+      private
+
+      def expired?(stored)
+        return false unless stored[:expires_at]
+
+        Time.now.to_i >= stored[:expires_at] - 60
+      end
+
+      def authorize
+        metadata = discover_auth_server
+        client = load_or_register_client(metadata)
+        verifier, challenge = generate_pkce
+        state = SecureRandom.hex(16)
+
+        auth_url = build_auth_url(metadata, client, challenge, state)
+        open_browser(auth_url)
+
+        code = wait_for_callback(state)
+        tokens = exchange_code(metadata, client, code, verifier)
+
+        @storage.save(
+          @resource_url,
+          access_token: tokens["access_token"],
+          refresh_token: tokens["refresh_token"],
+          expires_in: tokens["expires_in"]
+        )
+
+        tokens["access_token"]
+      end
+
+      def refresh(refresh_token)
+        metadata = discover_auth_server
+        client = load_or_register_client(metadata)
+        uri = URI.parse(metadata["token_endpoint"])
+
+        body = {
+          grant_type: "refresh_token",
+          refresh_token: refresh_token,
+          client_id: client[:client_id]
+        }
+
+        response = post_form(uri, body)
+        tokens = JSON.parse(response.body)
+
+        @storage.save(
+          @resource_url,
+          access_token: tokens["access_token"],
+          refresh_token: tokens["refresh_token"] || refresh_token,
+          expires_in: tokens["expires_in"]
+        )
+
+        tokens["access_token"]
+      rescue StandardError => e
+        warn "Token refresh failed: #{e.message}"
+        authorize
+      end
+
+      def discover_auth_server
+        resource_uri = URI.parse(@resource_url)
+        metadata_url = "#{resource_uri.scheme}://#{resource_uri.host}/.well-known/oauth-protected-resource"
+
+        resource_metadata = fetch_json(metadata_url)
+        auth_server_url = resource_metadata["authorization_servers"]&.first
+        raise "No authorization server found" unless auth_server_url
+
+        auth_metadata_url = "#{auth_server_url}/.well-known/oauth-authorization-server"
+        fetch_json(auth_metadata_url)
+      end
+
+      def load_or_register_client(metadata)
+        stored = @storage.load_client(@resource_url)
+        return stored if stored
+
+        client = register_client(metadata)
+        @storage.save_client(@resource_url, client)
+        @storage.load_client(@resource_url)
+      end
+
+      def register_client(metadata)
+        endpoint = metadata["registration_endpoint"]
+        raise "Dynamic registration not supported" unless endpoint
+
+        body = {
+          client_name: "elelem",
+          redirect_uris: [REDIRECT_URI],
+          grant_types: %w[authorization_code refresh_token],
+          response_types: ["code"],
+          token_endpoint_auth_method: "none"
+        }
+
+        response = post_json(endpoint, body)
+        JSON.parse(response.body)
+      end
+
+      def generate_pkce
+        verifier = SecureRandom.urlsafe_base64(32)
+        challenge = Base64.urlsafe_encode64(
+          Digest::SHA256.digest(verifier),
+          padding: false
+        )
+        [verifier, challenge]
+      end
+
+      def build_auth_url(metadata, client, challenge, state)
+        params = {
+          response_type: "code",
+          client_id: client[:client_id],
+          redirect_uri: REDIRECT_URI,
+          scope: metadata["scopes_supported"]&.join(" ") || "openid",
+          state: state,
+          code_challenge: challenge,
+          code_challenge_method: "S256"
+        }
+
+        "#{metadata["authorization_endpoint"]}?#{URI.encode_www_form(params)}"
+      end
+
+      def open_browser(url)
+        commands = ["xdg-open", "open", "start"]
+        commands.each do |cmd|
+          return if system(cmd, url, out: File::NULL, err: File::NULL)
+        end
+        warn "Open this URL in your browser: #{url}"
+      end
+
+      def wait_for_callback(expected_state)
+        code = nil
+        @server = WEBrick::HTTPServer.new(
+          Port: CALLBACK_PORT,
+          Logger: WEBrick::Log.new(File::NULL),
+          AccessLog: []
+        )
+
+        at_exit { @server&.shutdown }
+
+        @server.mount_proc("/callback") do |req, res|
+          state = req.query["state"]
+          raise "State mismatch" unless state == expected_state
+
+          code = req.query["code"]
+          res.content_type = "text/html"
+          res.body = "<html><body><h1>Authorization complete</h1><p>You can close this window.</p></body></html>"
+          @server.shutdown
+        end
+
+        Timeout.timeout(120) { @server.start }
+        code
+      rescue Timeout::Error
+        @server.shutdown
+        raise "OAuth callback timed out"
+      end
+
+      def exchange_code(metadata, client, code, verifier)
+        uri = URI.parse(metadata["token_endpoint"])
+
+        body = {
+          grant_type: "authorization_code",
+          code: code,
+          redirect_uri: REDIRECT_URI,
+          client_id: client[:client_id],
+          code_verifier: verifier
+        }
+
+        response = post_form(uri, body)
+        JSON.parse(response.body)
+      end
+
+      def fetch_json(url)
+        response = nil
+        @http.get(url) { |r| response = r }
+        JSON.parse(response.body)
+      end
+
+      def post_json(url, body)
+        response = nil
+        @http.post(
+          url,
+          headers: { "Content-Type" => "application/json" },
+          body: body.to_json
+        ) { |r| response = r }
+        response
+      end
+
+      def post_form(uri, body)
+        response = nil
+        @http.post(
+          uri.to_s,
+          headers: { "Content-Type" => "application/x-www-form-urlencoded" },
+          body: URI.encode_www_form(body)
+        ) { |r| response = r }
+        response
+      end
+    end
+  end
+end
lib/elelem/mcp/token_storage.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module Elelem
+  class MCP
+    class TokenStorage
+      STORAGE_DIR = File.expand_path("~/.config/elelem/tokens")
+
+      def initialize
+        FileUtils.mkdir_p(STORAGE_DIR, mode: 0o700)
+      end
+
+      def save(resource_url, access_token:, refresh_token: nil, expires_in: nil)
+        data = {
+          access_token: access_token,
+          refresh_token: refresh_token,
+          expires_at: expires_in ? Time.now.to_i + expires_in : nil
+        }
+        path = token_path(resource_url)
+        File.write(path, data.to_json)
+        File.chmod(0o600, path)
+      end
+
+      def load(resource_url)
+        path = token_path(resource_url)
+        return nil unless File.exist?(path)
+
+        JSON.parse(File.read(path), symbolize_names: true)
+      rescue JSON::ParserError
+        nil
+      end
+
+      def save_client(resource_url, client_data)
+        path = client_path(resource_url)
+        File.write(path, client_data.to_json)
+        File.chmod(0o600, path)
+      end
+
+      def load_client(resource_url)
+        path = client_path(resource_url)
+        return nil unless File.exist?(path)
+
+        JSON.parse(File.read(path), symbolize_names: true)
+      rescue JSON::ParserError
+        nil
+      end
+
+      private
+
+      def token_path(resource_url)
+        hash = Digest::SHA256.hexdigest(resource_url)[0, 16]
+        File.join(STORAGE_DIR, "#{hash}.json")
+      end
+
+      def client_path(resource_url)
+        hash = Digest::SHA256.hexdigest(resource_url)[0, 16]
+        File.join(STORAGE_DIR, "#{hash}_client.json")
+      end
+    end
+  end
+end
lib/elelem/net/claude.rb
@@ -38,7 +38,7 @@ module Elelem
           handle_event(event, tool_calls, &block)
         end
 
-        finalize_tool_calls(tool_calls)
+        finalize_tool_calls(tool_calls, &block)
       end
 
       private
@@ -72,19 +72,21 @@ module Elelem
 
         case delta["type"]
         when "text_delta"
-          block.call(content: delta["text"], thinking: nil)
+          block.call(type: "saying", text: delta["text"])
         when "thinking_delta"
-          block.call(content: nil, thinking: delta["thinking"])
+          block.call(type: "thinking", text: delta["thinking"])
         when "input_json_delta"
           tool_calls.last[:args] << delta["partial_json"].to_s if tool_calls.any?
         end
       end
 
-      def finalize_tool_calls(tool_calls)
+      def finalize_tool_calls(tool_calls, &block)
         tool_calls.each do |tool_call|
           args = tool_call.delete(:args)
           tool_call[:arguments] = args.empty? ? {} : JSON.parse(args)
+          block.call(type: "tool_call", id: tool_call[:id], name: tool_call[:name], arguments: tool_call[:arguments])
         end
+        tool_calls
       end
 
       def stream(messages, system_prompt, tools)
lib/elelem/net/ollama.rb
@@ -35,11 +35,14 @@ module Elelem
         message = event["message"] || {}
 
         unless event["done"]
-          block.call(content: message["content"], thinking: message["thinking"])
+          block.call(type: "saying", text: message["content"]) if message["content"]
+          block.call(type: "thinking", text: message["thinking"]) if message["thinking"]
         end
 
         if message["tool_calls"]
-          tool_calls.concat(parse_tool_calls(message["tool_calls"]))
+          parsed = parse_tool_calls(message["tool_calls"])
+          parsed.each { |tc| block.call(type: "tool_call", **tc) }
+          tool_calls.concat(parsed)
         end
       end
 
lib/elelem/net/openai.rb
@@ -18,7 +18,7 @@ module Elelem
           handle_event(event, tool_calls, &block)
         end
 
-        finalize_tool_calls(tool_calls)
+        finalize_tool_calls(tool_calls, &block)
       end
 
       private
@@ -30,7 +30,7 @@ module Elelem
       def handle_event(event, tool_calls, &block)
         delta = event.dig("choices", 0, "delta") || {}
 
-        block.call(content: delta["content"], thinking: nil) if delta["content"]
+        block.call(type: "saying", text: delta["content"]) if delta["content"]
 
         accumulate_tool_calls(delta["tool_calls"], tool_calls) if delta["tool_calls"]
       end
@@ -72,13 +72,15 @@ module Elelem
         end
       end
 
-      def finalize_tool_calls(tool_calls)
+      def finalize_tool_calls(tool_calls, &block)
         tool_calls.values.map do |tool_call|
-          {
+          result = {
             id: tool_call[:id],
             name: tool_call[:name],
             arguments: JSON.parse(tool_call[:args])
           }
+          block.call(type: "tool_call", **result)
+          result
         end
       end
     end
lib/elelem/plugins/builtins.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:builtins) do |agent|
+  agent.commands.register("exit", description: "Exit elelem") { exit(0) }
+
+  agent.commands.register("clear", description: "Clear conversation history") do
+    agent.conversation.clear!
+    agent.terminal.say "  → context cleared"
+  end
+
+  agent.commands.register("context", description: "Show conversation context") do |args|
+    messages = agent.context
+
+    case args
+    when nil, ""
+      messages.each_with_index do |msg, i|
+        role = msg[:role]
+        preview = msg[:content].to_s.lines.first&.strip&.slice(0, 60) || ""
+        preview += "..." if msg[:content].to_s.length > 60
+        agent.terminal.say "  #{i + 1}. #{role}: #{preview}"
+      end
+    when "json"
+      agent.terminal.say JSON.pretty_generate(messages)
+    when /^\d+$/
+      index = args.to_i - 1
+      if index >= 0 && index < messages.length
+        content = messages[index][:content].to_s
+        agent.terminal.say(agent.terminal.markdown(content))
+      else
+        agent.terminal.say "  Invalid index: #{args}"
+      end
+    else
+      agent.terminal.say "  Usage: /context [json|<number>]"
+    end
+  end
+
+  strip_ansi = ->(text) do
+    text
+      .gsub(/^Script started.*?\n/, "")
+      .gsub(/\nScript done.*$/, "")
+      .gsub(/\e\].*?(?:\a|\e\\)/, "")
+      .gsub(/\e\[[0-9;?]*[A-Za-z]/, "")
+      .gsub(/\e[PX^_].*?\e\\/, "")
+      .gsub(/\e./, "")
+      .gsub(/[\b]/, "")
+      .gsub(/\r/, "")
+  end
+
+  agent.commands.register("shell", description: "Start interactive shell") do
+    transcript = Tempfile.create do |file|
+      system("script", "-q", file.path, chdir: Dir.pwd)
+      strip_ansi.call(File.read(file.path))
+    end
+    agent.conversation.add(role: "user", content: transcript) unless transcript.strip.empty?
+  end
+
+  agent.commands.register("init", description: "Generate AGENTS.md") do
+    system_prompt = <<~PROMPT
+      AGENTS.md generator. Analyze codebase and write AGENTS.md to project root.
+
+      # AGENTS.md Spec (https://agents.md/)
+      A file providing context and instructions for AI coding agents.
+
+      ## Recommended Sections
+      - Commands: build, test, lint commands
+      - Code Style: conventions, patterns
+      - Architecture: key components and flow
+      - Testing: how to run tests
+
+      ## Process
+      1. Read README.md if present
+      2. Identify language (Gemfile, package.json, go.mod)
+      3. Find test scripts (bin/test, npm test)
+      4. Check linter configs
+      5. Write concise AGENTS.md
+
+      Keep it minimal. No fluff.
+    PROMPT
+
+    agent.fork(system_prompt: system_prompt).turn("Generate AGENTS.md for this project")
+  end
+
+  agent.commands.register("reload", description: "Reload plugins and source") do
+    lib_dir = File.expand_path("../..", __dir__)
+    original_verbose, $VERBOSE = $VERBOSE, nil
+    Dir["#{lib_dir}/**/*.rb"].sort.each { |f| load(f) }
+    $VERBOSE = original_verbose
+    agent.toolbox = Elelem::Toolbox.new
+    agent.commands = Elelem::Commands.new
+    Elelem::Plugins.reload!(agent)
+  end
+
+  agent.commands.register("help", description: "Show available commands") do
+    agent.terminal.say agent.commands.names.join(" ")
+  end
+end
lib/elelem/plugins/confirm.rb
@@ -1,12 +0,0 @@
-# frozen_string_literal: true
-
-Elelem::Plugins.register(:confirm) do |toolbox|
-  toolbox.before("execute") do |args|
-    next unless $stdin.tty?
-
-    cmd = args["command"]
-    $stdout.print "  Allow? [Y/n] > "
-    answer = $stdin.gets&.strip&.downcase
-    raise "User denied permission to execute: #{cmd}" if answer == "n"
-  end
-end
lib/elelem/plugins/edit.rb
@@ -1,14 +1,14 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:edit) do |toolbox|
-  toolbox.add("edit",
+Elelem::Plugins.register(:edit) do |agent|
+  agent.toolbox.add("edit",
     description: "Replace first occurrence of text in file",
     params: { path: { type: "string" }, old: { type: "string" }, new: { type: "string" } },
     required: ["path", "old", "new"]
   ) do |a|
     path = Pathname.new(a["path"]).expand_path
     content = path.read
-    toolbox
+    agent.toolbox
       .run("write", { "path" => a["path"], "content" => content.sub(a["old"], a["new"]) })
       .merge(replaced: a["old"], with: a["new"])
   end
lib/elelem/plugins/eval.rb
@@ -1,16 +1,16 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:eval) do |toolbox|
+Elelem::Plugins.register(:eval) do |agent|
   description = <<~'DESC'
     Evaluate Ruby code. Available API:
 
     name = "search"
-    toolbox.add(name, description: "Search using rg", params: { query: { type: "string" } }, required: ["query"], aliases: []) do |args|
-      toolbox.run("execute", { "command" => "rg --json -nI -F #{args["query"]}" })
+    agent.toolbox.add(name, description: "Search using rg", params: { query: { type: "string" } }, required: ["query"], aliases: []) do |args|
+      agent.toolbox.run("execute", { "command" => "rg --json -nI -F #{args["query"]}" })
     end
   DESC
 
-  toolbox.add("eval",
+  agent.toolbox.add("eval",
     description: description,
     params: { ruby: { type: "string" } },
     required: ["ruby"]
lib/elelem/plugins/execute.rb
@@ -1,18 +1,18 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:execute) do |toolbox|
-  toolbox.add("execute",
+Elelem::Plugins.register(:execute) do |agent|
+  agent.toolbox.add("execute",
     description: "Run shell command (supports pipes and redirections)",
     params: { command: { type: "string" } },
     required: ["command"],
     aliases: ["bash", "sh", "exec", "execute<|channel|>"]
   ) do |a|
-    Elelem.sh("bash", args: ["-c", a["command"]]) { |x| $stdout.print(x) }
+    Elelem.sh("bash", args: ["-c", a["command"]]) { |x| agent.terminal.print(x) }
   end
 
-  toolbox.after("execute") do |args, result|
+  agent.toolbox.after("execute") do |args, result|
     next if result[:exit_status] == 0
 
-    $stdout.puts toolbox.header("execute", args, state: "x")
+    agent.terminal.say agent.toolbox.header("execute", args, state: "x")
   end
 end
lib/elelem/plugins/git.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:git) do |agent|
+  allowed = %w[status diff log show branch checkout add reset stash].freeze
+
+  agent.toolbox.add("git",
+    description: "Run git command",
+    params: { command: { type: "string" }, args: { type: "array" } },
+    required: ["command"]
+  ) do |a|
+    cmd = a["command"]
+    next { error: "not allowed: #{cmd}" } unless allowed.include?(cmd)
+
+    agent.toolbox.exec("git", cmd, *(a["args"] || []))
+  end
+
+  agent.toolbox.after("git") do |_, result|
+    agent.terminal.say "  ! #{result[:error]}" if result[:error]
+  end
+end
lib/elelem/plugins/glob.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:glob) do |agent|
+  agent.toolbox.add("glob",
+    description: "Find files matching pattern",
+    params: { pattern: { type: "string" }, path: { type: "string" } },
+    required: ["pattern"]
+  ) do |a|
+    path = a["path"] || "."
+    result = agent.toolbox.exec("fd", "--glob", a["pattern"], path)
+    result[:ok] ? result : agent.toolbox.exec("find", path, "-name", a["pattern"])
+  end
+end
lib/elelem/plugins/grep.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:grep) do |agent|
+  agent.toolbox.add("grep",
+    description: "Search file contents",
+    params: { pattern: { type: "string" }, path: { type: "string" }, glob: { type: "string" } },
+    required: ["pattern"]
+  ) do |a|
+    path = a["path"] || "."
+    glob = a["glob"]
+    rg_args = ["rg", "-n", a["pattern"], path]
+    rg_args += ["-g", glob] if glob
+    result = agent.toolbox.exec(*rg_args)
+    next result if result[:ok]
+
+    grep_args = ["grep", "-rn"]
+    grep_args += ["--include", glob] if glob
+    grep_args += [a["pattern"], path]
+    agent.toolbox.exec(*grep_args)
+  end
+end
lib/elelem/plugins/list.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:list) do |agent|
+  agent.toolbox.add("list",
+    description: "List directory contents",
+    params: { path: { type: "string" }, recursive: { type: "boolean" } },
+    required: [],
+    aliases: ["ls"]
+  ) do |a|
+    path = a["path"] || "."
+    flags = a["recursive"] ? "-laR" : "-la"
+    agent.toolbox.exec("ls", flags, path)
+  end
+end
lib/elelem/plugins/mcp.rb
@@ -1,14 +1,20 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:mcp) do |toolbox|
+Elelem::Plugins.register(:mcp) do |agent|
   mcp = Elelem::MCP.new
   at_exit { mcp.close }
-  mcp.tools.each do |name, tool|
-    toolbox.add(name,
-      description: tool[:description],
-      params: tool[:params],
-      required: tool[:required],
-      &tool[:fn]
-    )
+
+  Thread.new do
+    mcp.tools.each do |name, tool|
+      agent.toolbox.add(
+        name,
+        description: tool[:description],
+        params: tool[:params],
+        required: tool[:required],
+        &tool[:fn]
+      )
+    end
+  rescue => e
+    warn "MCP failed: #{e.message}"
   end
 end
lib/elelem/plugins/permissions.json
@@ -0,0 +1,6 @@
+{
+  "read": "allow",
+  "write": "ask",
+  "edit": "ask",
+  "execute": "ask"
+}
lib/elelem/plugins/read.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:read) do |toolbox|
-  toolbox.add("read",
+Elelem::Plugins.register(:read) do |agent|
+  agent.toolbox.add("read",
     description: "Read file",
     params: { path: { type: "string" } },
     required: ["path"],
@@ -11,11 +11,11 @@ Elelem::Plugins.register(:read) do |toolbox|
     path.exist? ? { content: path.read, path: a["path"] } : { error: "not found" }
   end
 
-  toolbox.after("read") do |_, result|
+  agent.toolbox.after("read") do |_, result|
     if result[:error]
-      $stdout.puts "  ! #{result[:error]}"
-    elsif !system("bat", "--paging=never", result[:path])
-      $stdout.puts result[:content]
+      agent.terminal.say "  ! #{result[:error]}"
+    else
+      agent.terminal.display_file(result[:path], fallback: result[:content])
     end
   end
 end
lib/elelem/plugins/task.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:task) do |agent|
+  agent.toolbox.add("task",
+    description: "Delegate subtask to focused agent (complex searches, multi-file analysis)",
+    params: { prompt: { type: "string" } },
+    required: ["prompt"]
+  ) do |a|
+    sub = Elelem::Agent.new(agent.client, toolbox: agent.toolbox, terminal: agent.terminal,
+      system_prompt: "Research agent. Search, analyze, report. Be concise.")
+    sub.turn(a["prompt"])
+    { result: sub.conversation.last[:content] }
+  end
+end
lib/elelem/plugins/tools.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:tools) do |agent|
+  agent.commands.register("tools", description: "List available tools") do
+    agent.toolbox.tools.each_value do |tool|
+      agent.terminal.say ""
+      agent.terminal.say "  #{tool.name}"
+      agent.terminal.say "    #{tool.description}"
+      tool.params.each { |k, v| agent.terminal.say "      #{k}: #{v[:type] || v["type"]}" }
+      agent.terminal.say "    aliases: #{tool.aliases.join(", ")}" if tool.aliases.any?
+    end
+  end
+end
lib/elelem/plugins/verify.rb
@@ -27,16 +27,16 @@ module Elelem
     end
   end
 
-  Plugins.register(:verify) do |toolbox|
-    toolbox.add("verify",
+  Plugins.register(:verify) do |agent|
+    agent.toolbox.add("verify",
       description: "Verify file syntax and run tests",
       params: { path: { type: "string" } },
       required: ["path"]
     ) do |a|
       path = a["path"]
       Verifiers.for(path).inject({verified: []}) do |memo, cmd|
-        $stdout.puts toolbox.header("execute", { "command" => cmd })
-        v = toolbox.run("execute", { "command" => cmd })
+        agent.terminal.say agent.toolbox.header("execute", { "command" => cmd })
+        v = agent.toolbox.run("execute", { "command" => cmd })
         break v.merge(path: path, command: cmd) if v[:exit_status] != 0
 
         memo[:verified] << cmd
lib/elelem/plugins/write.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:write) do |toolbox|
-  toolbox.add("write",
+Elelem::Plugins.register(:write) do |agent|
+  agent.toolbox.add("write",
     description: "Write file",
     params: { path: { type: "string" }, content: { type: "string" } },
     required: ["path", "content"],
@@ -12,12 +12,23 @@ Elelem::Plugins.register(:write) do |toolbox|
     { bytes: path.write(a["content"]), path: a["path"] }
   end
 
-  toolbox.after("write") do |_, result|
+  agent.toolbox.before("write") do |args|
+    path = Pathname.new(args["path"]).expand_path
+    next unless path.exist? && $stdin.tty?
+
+    Tempfile.create(["elelem", File.extname(path)]) do |t|
+      t.write(args["content"])
+      t.flush
+      system("diff", "--color=always", "-u", path.to_s, t.path)
+    end
+  end
+
+  agent.toolbox.after("write") do |_, result|
     if result[:error]
-      $stdout.puts "  ! #{result[:error]}"
+      agent.terminal.say "  ! #{result[:error]}"
     else
-      system("bat", "--paging=never", result[:path]) || $stdout.puts("  -> #{result[:path]}")
-      toolbox.run("verify", { "path" => result[:path] })
+      agent.terminal.display_file(result[:path], fallback: "  -> #{result[:path]}")
+      agent.toolbox.run("verify", { "path" => result[:path] })
     end
   end
 end
lib/elelem/plugins/zz_confirm.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:confirm) do |agent|
+  permissions = Elelem::Permissions.new
+
+  agent.toolbox.before do |args, tool_name:|
+    permissions.check(tool_name, args, terminal: agent.terminal)
+  end
+end
lib/elelem/templates/system_prompt.erb
@@ -1,53 +0,0 @@
-Terminal coding agent. Be concise. Verify your work.
-
-# Tools
-- read(path): file contents
-- write(path, content): create/overwrite file
-- execute(command): shell command
-- eval(ruby): execute Ruby code; use to create tools for repetitive tasks
-- task(prompt): delegate complex searches or multi-file analysis to a focused subagent
-
-# Editing
-Use execute(`patch -p1`) for multi-line changes: `echo "DIFF" | patch -p1`
-Use execute(`sed`) for single-line changes: `sed -i'' 's/old/new/' file`
-Use write for new files or full rewrites
-
-# Search
-Use execute(`rg`) for text search: `rg -n "pattern" .`
-Use execute(`fd`) for file discovery: `fd -e rb .`
-Use execute(`sg`) (ast-grep) for structural search: `sg -p 'def $NAME' -l ruby`
-
-# Task Management
-For complex tasks:
-1. State plan before acting
-2. Work through steps one at a time
-3. Summarize what was done
-
-# Long Tasks
-For complex multi-step work, write notes to .elelem/scratch.md
-
-# Policy
-- Explain before non-trivial commands
-- Verify changes (read file, run tests)
-- No interactive flags (-i, -p)
-- Use `man` when you need to understand how to execute a program
-
-# Environment
-pwd: <%= pwd %>
-platform: <%= platform %>
-date: <%= date %>
-self (this agent's source): <%= elelem_source %>
-<%= git_branch %>
-
-# Codebase
-<%= repo_map %>
-<% if agents_md %>
-
-# Project Instructions
-<%= agents_md %>
-<% end %>
-<% if memory %>
-
-# Earlier Context
-<%= memory %>
-<% end %>
lib/elelem/agent.rb
@@ -2,40 +2,16 @@
 
 module Elelem
   class Agent
-    COMMANDS = %w[/clear /context /init /reload /shell /exit /help].freeze
-    MAX_CONTEXT_MESSAGES = 50
-    INIT_PROMPT = <<~PROMPT
-      AGENTS.md generator. Analyze codebase and write AGENTS.md to project root.
+    attr_reader :conversation, :client, :toolbox, :terminal, :commands
+    attr_writer :terminal, :toolbox, :commands
 
-      # AGENTS.md Spec (https://agents.md/)
-      A file providing context and instructions for AI coding agents.
-
-      ## Recommended Sections
-      - Commands: build, test, lint commands
-      - Code Style: conventions, patterns
-      - Architecture: key components and flow
-      - Testing: how to run tests
-
-      ## Process
-      1. Read README.md if present
-      2. Identify language (Gemfile, package.json, go.mod)
-      3. Find test scripts (bin/test, npm test)
-      4. Check linter configs
-      5. Write concise AGENTS.md
-
-      Keep it minimal. No fluff.
-    PROMPT
-
-    attr_reader :history, :client, :toolbox, :terminal
-
-    def initialize(client, toolbox, terminal: nil, history: nil, system_prompt: nil)
+    def initialize(client, toolbox: Toolbox.new, terminal: nil, system_prompt: nil, commands: nil)
       @client = client
       @toolbox = toolbox
-      @terminal = terminal || Terminal.new(commands: COMMANDS)
-      @history = history || []
+      @commands = commands || Commands.new
+      @terminal = terminal
+      @conversation = Conversation.new
       @system_prompt = system_prompt
-      @memory = nil
-      register_task_tool
     end
 
     def repl
@@ -49,27 +25,21 @@ module Elelem
     end
 
     def command(input)
-      case input
-      when "/exit" then exit(0)
-      when "/init" then init_agents_md
-      when "/reload" then reload_source!
-      when "/shell"
-        transcript = start_shell
-        history << { role: "user", content: transcript } unless transcript.strip.empty?
-      when "/clear"
-        @history = []
-        @memory = nil
-        terminal.say "  → context cleared"
-      when "/context"
-        terminal.say JSON.pretty_generate(combined_history)
-      else
-        terminal.say COMMANDS.join(" ")
-      end
+      parts = input.delete_prefix("/").split(" ", 2)
+      name, args = parts[0], parts[1]
+      commands.run(name, args) || terminal.say(commands.names.join(" "))
+    end
+
+    def context
+      @conversation.to_a(system_prompt: system_prompt)
+    end
+
+    def fork(system_prompt:)
+      Agent.new(client, toolbox: toolbox, terminal: terminal, system_prompt: system_prompt)
     end
 
     def turn(input)
-      compact_if_needed
-      history << { role: "user", content: input }
+      @conversation.add(role: "user", content: input)
       ctx = []
       content = nil
 
@@ -85,7 +55,7 @@ module Elelem
         end
       end
 
-      history << { role: "assistant", content: content }
+      @conversation.add(role: "assistant", content: content)
       content
     end
 
@@ -97,90 +67,29 @@ module Elelem
       toolbox.run(name.to_s, args)
     end
 
-    def register_task_tool
-      @toolbox.add("task",
-        description: "Delegate subtask to focused agent (complex searches, multi-file analysis)",
-        params: { prompt: { type: "string" } },
-        required: ["prompt"]
-      ) do |a|
-        sub = Agent.new(client, toolbox, terminal: terminal,
-          system_prompt: "Research agent. Search, analyze, report. Be concise.")
-        sub.turn(a["prompt"])
-        { result: sub.history.last[:content] }
-      end
-    end
-
-    def init_agents_md
-      sub = Agent.new(client, toolbox, terminal: terminal, system_prompt: INIT_PROMPT)
-      sub.turn("Generate AGENTS.md for this project")
-    end
-
-    def reload_source!
-      lib_dir = File.expand_path("..", __dir__)
-      original_verbose, $VERBOSE = $VERBOSE, nil
-      Dir["#{lib_dir}/**/*.rb"].sort.each { |f| load(f) }
-      $VERBOSE = original_verbose
-      @toolbox = Toolbox.new
-      Plugins.reload!(@toolbox)
-      register_task_tool
-    end
-
-    def start_shell
-      Tempfile.create do |file|
-        system("script", "-q", file.path, chdir: Dir.pwd)
-        strip_ansi(File.read(file.path))
-      end
-    end
-
-    def strip_ansi(text)
-      text.gsub(/^Script started.*?\n/, "")
-          .gsub(/\nScript done.*$/, "")
-          .gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
-          .gsub(/\e\[\?[0-9]+[hl]/, "")
-          .gsub(/[\b]/, "")
-          .gsub(/\r/, "")
-    end
-
     def fetch_response(ctx)
-      content = ""
-      tool_calls = client.fetch(combined_history + ctx, toolbox.to_a) do |delta|
-        content += delta[:content].to_s
-        terminal.print(terminal.think(delta[:thinking])) if delta[:thinking]
+      content = String.new
+      tool_calls = []
+
+      client.fetch(@conversation.to_a(system_prompt: system_prompt) + ctx, toolbox.to_a) do |event|
+        case event[:type]
+        when "saying"
+          content << event[:text].to_s
+        when "thinking"
+          terminal.print(terminal.think(event[:text]))
+        when "tool_call"
+          tool_calls << { id: event[:id], name: event[:name], arguments: event[:arguments] }
+        end
       end
+
       [content, tool_calls]
     rescue => e
       terminal.say "\n  ✗ #{e.message}"
       ["Error: #{e.message} #{e.backtrace.join("\n")}", []]
     end
 
-    def combined_history
-      [{ role: "system", content: system_prompt }] + history
-    end
-
     def system_prompt
-      @system_prompt || SystemPrompt.new(memory: @memory).render
-    end
-
-    def compact_if_needed
-      return if history.length <= MAX_CONTEXT_MESSAGES
-
-      terminal.say "  → compacting context"
-      keep = MAX_CONTEXT_MESSAGES / 2
-      old = history.first(history.length - keep)
-
-      to_summarize = @memory ? [{ role: "memory", content: @memory }, *old] : old
-      @memory = summarize(to_summarize)
-      @history = history.last(keep)
-    end
-
-    def summarize(messages)
-      text = messages.map { |message| { role: message[:role], content: message[:content] } }.to_json
-
-      String.new.tap do |buffer|
-        client.fetch([{ role: "user", content: "Summarize key facts:\n#{text}" }], []) do |d|
-          buffer << d[:content].to_s
-        end
-      end
+      @system_prompt || SystemPrompt.new.render
     end
   end
 end
lib/elelem/commands.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Elelem
+  class Commands
+    def initialize
+      @registry = {}
+    end
+
+    def register(name, description: "", &handler)
+      @registry[name] = { description: description, handler: handler }
+    end
+
+    def run(name, args = nil)
+      entry = @registry[name]
+      return false unless entry
+
+      entry[:handler].arity == 0 ? entry[:handler].call : entry[:handler].call(args)
+      true
+    end
+
+    def names
+      @registry.keys.map { |name| "/#{name}" }
+    end
+
+    def each
+      @registry.each { |name, entry| yield "/#{name}", entry[:description] }
+    end
+
+    def include?(name)
+      @registry.key?(name)
+    end
+  end
+end
lib/elelem/conversation.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Elelem
+  class Conversation
+    ROLES = %w[user assistant tool].freeze
+
+    def initialize
+      @messages = []
+    end
+
+    def add(role:, content:, **extra)
+      raise ArgumentError, "invalid role: #{role}" unless ROLES.include?(role)
+      @messages << { role: role, content: content, **extra }.compact
+    end
+
+    def last = @messages.last
+    def length = @messages.length
+    def clear! = @messages.clear
+
+    def to_a(system_prompt: nil)
+      base = system_prompt ? [{ role: "system", content: system_prompt }] : []
+      base + @messages
+    end
+  end
+end
lib/elelem/mcp.rb
@@ -1,9 +1,18 @@
 # frozen_string_literal: true
 
+require_relative "mcp/token_storage"
+require_relative "mcp/oauth"
+
 module Elelem
+  # https://modelcontextprotocol.io/specification/2025-11-25/server/tools.md
   class MCP
-    def initialize(config_path = ".mcp.json")
-      @config = File.exist?(config_path) ? JSON.parse(IO.read(config_path)) : {}
+    CONFIG_PATHS = [
+      "~/.elelem/mcp.json",
+      ".elelem/mcp.json"
+    ].freeze
+
+    def initialize(configurations = CONFIG_PATHS)
+      @config = load_config(configurations)
       @servers = {}
     end
 
@@ -29,44 +38,83 @@ module Elelem
 
     private
 
+    def load_config(configurations)
+      configurations.each_with_object({}) do |path, merged|
+        file = File.expand_path(path)
+        next unless File.exist?(file)
+
+        config = JSON.parse(IO.read(file))
+        servers = config.fetch("mcpServers", {})
+        merged["mcpServers"] = (merged["mcpServers"] || {}).merge(servers)
+      end
+    end
+
     def server(name)
-      @servers[name] ||= Server.new(**@config.dig("mcpServers", name).transform_keys(&:to_sym))
+      @servers[name] ||= build_server(@config.dig("mcpServers", name))
     end
 
-    class Server
-      def initialize(command:, args: [], env: {})
-        resolved_env = env.transform_values { |v| v.gsub(/\$\{(\w+)\}/) { ENV[$1] } }
-        @stdin, @stdout, @stderr, @wait = Open3.popen3(resolved_env, command, *args)
-        @id = 0
-        initialize!
+    def build_server(config)
+      if config["type"] == "http"
+        HttpServer.new(url: config["url"], headers: config["headers"] || {})
+      else
+        Server.new(**config.transform_keys(&:to_sym))
       end
+    end
 
+    module ServerInterface
       def tools
         request("tools/list")["tools"]
       end
 
       def call(name, args)
         result = request("tools/call", { name: name, arguments: args })
-        { content: result["content"]&.map { |c| c["text"] }&.join("\n") }
+        logger.info({ tool: name, args: args, result: result }.to_json)
+        content = extract_content(result)
+        result["isError"] ? { error: content } : { content: content }
       end
 
-      def close
-        @stdin.close rescue nil
-        @stdout.close rescue nil
-        @stderr.close rescue nil
-        @wait.kill rescue nil
+      def extract_content(result)
+        if (structured = result["structuredContent"])
+          structured
+        else
+          result["content"]&.map { |c| c["text"] }&.join("\n")
+        end
+      end
+
+      def logger
+        @logger ||= Logger.new(File.expand_path("~/.elelem/mcp.log"))
       end
 
       private
 
-      def initialize!
+      def handshake!
         request("initialize", {
-          protocolVersion: "2024-11-05",
+          protocolVersion: "2025-06-18",
           capabilities: {},
           clientInfo: { name: "elelem", version: VERSION }
         })
         notify("notifications/initialized")
       end
+    end
+
+    class Server
+      include ServerInterface
+
+      def initialize(command:, args: [], env: {})
+        resolved_env = env.transform_values do |v|
+          v.gsub(/\$\{(\w+)\}/) { ENV[$1] || raise("Missing environment variable: #{$1}") }
+        end
+        @stdin, @stdout, @stderr, @wait = Open3.popen3(resolved_env, command, *args)
+        @id = 0
+        handshake!
+      end
+
+      def close
+        [@stdin, @stdout, @stderr].each { |io| io.close rescue nil }
+        @wait.kill rescue nil
+      end
+
+      private
 
       def request(method, params = {})
         send_msg(id: @id += 1, method: method, params: params)
@@ -92,5 +140,104 @@ module Elelem
         end
       end
     end
+
+    class HttpServer
+      include ServerInterface
+
+      def initialize(url:, headers: {}, http: Elelem::Net.http)
+        @url = url
+        @headers = resolve_headers(headers)
+        @http = http
+        @id = 0
+        @session_id = nil
+        @access_token = nil
+        handshake!
+      end
+
+      def close
+      end
+
+      private
+
+      def resolve_headers(headers)
+        headers.transform_values do |v|
+          v.gsub(/\$\{(\w+)\}/) do
+            ENV[$1] || raise("Missing environment variable: #{$1}")
+          end
+        end
+      end
+
+      def request(method, params = {})
+        msg = { jsonrpc: "2.0", id: @id += 1, method: method, params: params }
+        response = post(msg)
+        raise response["error"]["message"] if response["error"]
+        response["result"]
+      end
+
+      def notify(method, params = {})
+        msg = { jsonrpc: "2.0", method: method, params: params }
+        post(msg)
+      end
+
+      def post(msg, retry_auth: true)
+        result = nil
+        needs_auth = false
+        error = nil
+
+        @http.post(@url, headers: request_headers, body: msg) do |response|
+          case response
+          when ::Net::HTTPSuccess
+            @session_id ||= response["Mcp-Session-Id"]
+            result = parse_response(response)
+          when ::Net::HTTPUnauthorized
+            needs_auth = true
+          else
+            error = "HTTP #{response.code}: #{response.body}"
+          end
+        end
+
+        raise error if error
+        if needs_auth
+          raise "Authorization failed" unless retry_auth
+
+          @access_token = OAuth.new(@url, http: @http).token
+          return post(msg, retry_auth: false)
+        end
+        result
+      end
+
+      def request_headers
+        base = { "Accept" => "application/json, text/event-stream" }
+        base["Mcp-Session-Id"] = @session_id if @session_id
+        base["Authorization"] = "Bearer #{@access_token}" if @access_token
+        @headers.merge(base)
+      end
+
+      def parse_response(response)
+        if response.content_type&.include?("text/event-stream")
+          parse_sse(response)
+        elsif response.body && !response.body.empty?
+          JSON.parse(response.body)
+        end
+      end
+
+      def parse_sse(response)
+        buffer = String.new
+        result = nil
+
+        response.read_body do |chunk|
+          buffer << chunk
+
+          while (index = buffer.index("\n"))
+            line = buffer.slice!(0, index + 1).strip
+            next unless line.start_with?("data: ")
+
+            result = JSON.parse(line.delete_prefix("data: "))
+          end
+        end
+
+        result
+      end
+    end
   end
 end
lib/elelem/net.rb
@@ -1,8 +1,5 @@
 # frozen_string_literal: true
 
-require "net/hippie"
-require "json"
-
 require_relative "net/ollama"
 require_relative "net/openai"
 require_relative "net/claude"
lib/elelem/permissions.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Elelem
+  class Permissions
+    LOAD_PATHS = [
+      File.expand_path("plugins/permissions.json", __dir__),
+      "~/.elelem/permissions.json",
+      ".elelem/permissions.json"
+    ].freeze
+
+    def initialize
+      @rules = LOAD_PATHS.reduce({}) do |rules, path|
+        rules.merge(load_config(File.expand_path(path)))
+      end
+    end
+
+    def check(tool_name, args, terminal:)
+      policy = @rules[tool_name.to_sym] || :ask
+      case policy
+      when :allow then true
+      when :deny then raise "Permission denied: #{tool_name}"
+      when :ask then prompt(tool_name, args, terminal)
+      end
+    end
+
+    private
+
+    def load_config(path)
+      return {} unless File.exist?(path)
+
+      JSON.parse(File.read(path)).transform_keys(&:to_sym).transform_values(&:to_sym)
+    rescue JSON::ParserError
+      {}
+    end
+
+    def prompt(tool_name, args, terminal)
+      return true unless $stdin.tty?
+
+      answer = terminal.ask("  Allow? [Y/n] > ")&.downcase
+      raise "User denied permission: #{tool_name}" if answer == "n"
+
+      true
+    end
+  end
+end
lib/elelem/plugins.rb
@@ -8,15 +8,15 @@ module Elelem
       ".elelem/plugins"
     ].freeze
 
-    def self.setup!(toolbox)
+    def self.setup!(agent)
       load_plugins
-      registry.each_value { |plugin| plugin.call(toolbox) }
+      registry.each_value { |plugin| plugin.call(agent) }
     end
 
-    def self.reload!(toolbox)
-      @registry = {}
+    def self.reload!(agent)
+      registry.clear
       load_plugins
-      registry.each_value { |plugin| plugin.call(toolbox) }
+      registry.each_value { |plugin| plugin.call(agent) }
     end
 
     def self.load_plugins
@@ -33,7 +33,7 @@ module Elelem
     end
 
     def self.register(name, &block)
-      (@registry ||= {})[name] = block
+      registry[name] = block
     end
 
     def self.registry
lib/elelem/system_prompt.rb
@@ -2,56 +2,150 @@
 
 module Elelem
   class SystemPrompt
-    TEMPLATE_PATH = File.expand_path("templates/system_prompt.erb", __dir__)
+    TEMPLATE = <<~ERB
+      Terminal coding agent. Be concise. Verify your work.
 
-    attr_reader :memory
+      # Tools
+      - read(path): file contents
+      - write(path, content): create/overwrite file
+      - execute(command): shell command
+      - eval(ruby): execute Ruby code; use to create tools for repetitive tasks
+      - task(prompt): delegate complex searches or multi-file analysis to a focused subagent
 
-    def initialize(memory: nil)
-      @memory = memory
-    end
+      # Editing
+      Use execute(`patch -p1`) for multi-line changes: `echo "DIFF" | patch -p1`
+      Use execute(`sed`) for single-line changes: `sed -i'' 's/old/new/' file`
+      Use write for new files or full rewrites
+
+      # Search
+      Use execute(`rg`) for text search: `rg -n "pattern" .`
+      Use execute(`fd`) for file discovery: `fd -e rb .`
+      Use execute(`sg`) (ast-grep) for structural search: `sg -p 'def $NAME' -l ruby`
+
+      # Task Management
+      For complex tasks:
+      1. State plan before acting
+      2. Work through steps one at a time
+      3. Summarize what was done
+
+      # Long Tasks
+      For complex multi-step work, write notes to .elelem/scratch.md
+
+      # Policy
+      - Explain before non-trivial commands
+      - Verify changes (read file, run tests)
+      - No interactive flags (-i, -p)
+      - Use `man` when you need to understand how to execute a program
+
+      # Environment
+      pwd: <%= pwd %>
+      platform: <%= platform %>
+      date: <%= date %>
+      self: <%= elelem_source %>
+      <%= git_info %>
+
+      <% if repo_map && !repo_map.empty? %>
+      # Codebase
+      ```
+      <%= repo_map %>```
+      <% end %>
+      <%= agents_md %>
+    ERB
 
     def render
-      ERB.new(template, trim_mode: "-").result(binding)
+      ERB.new(TEMPLATE, trim_mode: "-").result(binding)
     end
 
     private
 
-    def template
-      File.read(TEMPLATE_PATH)
+    def pwd = Dir.pwd
+    def platform = RUBY_PLATFORM.split("-").last
+    def date = Date.today
+
+    def elelem_source
+      spec = Gem.loaded_specs["elelem"]
+      spec ? spec.gem_dir : File.expand_path("../..", __dir__)
     end
 
-    def pwd
-      Dir.pwd
+    def git_info
+      return unless File.exist?(".git")
+      "branch: #{`git branch --show-current`.strip}"
+    rescue Errno::ENOENT
+      nil
     end
 
-    def elelem_source
-      File.expand_path("../..", __dir__)
+    def repo_map
+      files = `git ls-files '*.rb' 2>/dev/null`.lines.map(&:strip)
+      return "" if files.empty?
+
+      symbols = extract_symbols(files)
+      format_symbols(symbols, budget: 2000)
     end
 
-    def platform
-      RUBY_PLATFORM.split("-").last
+    def extract_symbols(files)
+      output, status = Open3.capture2("sg", "run", "-p", "def $NAME", "-l", "ruby", "--json=compact", ".", err: File::NULL)
+      return ctags_fallback(files) unless status.success?
+
+      parse_sg_output(output, files)
     end
 
-    def date
-      Date.today
+    def parse_sg_output(output, tracked_files)
+      JSON.parse(output).filter_map do |match|
+        file = match["file"]
+        next unless tracked_files.include?(file)
+        { file: file, name: match.dig("metaVariables", "single", "NAME", "text") }
+      end
+    rescue JSON::ParserError
+      []
     end
 
-    def git_branch
-      return unless File.exist?(".git")
+    def ctags_fallback(files)
+      return [] if files.empty?
 
-      "branch: #{`git branch --show-current`.strip}"
-    rescue
-      nil
+      output = IO.popen(["ctags", "-x", "--languages=Ruby", "--kinds-Ruby=cfm", "-L", "-"], "r+") do |io|
+        io.puts(files)
+        io.close_write
+        io.read
+      end
+
+      output.lines.map do |line|
+        parts = line.split(/\s+/, 4)
+        { file: parts[2], name: parts[0] }
+      end
+    rescue Errno::ENOENT
+      []
     end
 
-    def repo_map
-      `ctags -x --sort=no --languages=Ruby,Python,JavaScript,TypeScript,Go,Rust -R . 2>/dev/null`
-        .lines
-        .reject { |l| l.include?("vendor/") || l.include?("node_modules/") || l.include?("spec/") }
-        .first(100)
-        .join
-    rescue
-      ""
+    def format_symbols(symbols, budget:)
+      tree = build_tree(symbols)
+      render_tree(tree, budget: budget)
+    end
+
+    def build_tree(symbols)
+      tree = {}
+      symbols.group_by { |s| s[:file] }.each do |file, syms|
+        parts = file.split("/")
+        node = tree
+        parts[0..-2].each { |dir| node = (node[dir + "/"] ||= {}) }
+        node[parts.last] = syms.map { |s| s[:name] }.uniq
+      end
+      tree
+    end
+
+    def render_tree(node, indent: 0, budget:, result: String.new)
+      node.each do |key, value|
+        if value.is_a?(Hash)
+          line = "  " * indent + key + "\n"
+          return result if result.length + line.length > budget
+          result << line
+          render_tree(value, indent: indent + 1, budget: budget, result: result)
+        else
+          line = "  " * indent + key.sub(/\.rb$/, "") + ": " + value.join(" ") + "\n"
+          return result if result.length + line.length > budget
+          result << line
+        end
+      end
+      result
     end
 
     def agents_md
lib/elelem/terminal.rb
@@ -51,6 +51,12 @@ module Elelem
       n.times { $stdout.puts("") }
     end
 
+    def display_file(path, fallback: nil)
+      return if @quiet
+
+      system("bat", "--paging=never", path) || say(fallback || path)
+    end
+
     def waiting
       return if @quiet
 
@@ -66,7 +72,7 @@ module Elelem
     private
 
     def blank?(text)
-      text.nil? || text.strip.empty?
+      text.nil? || text.to_s.strip.empty?
     end
 
     def stop_dots
lib/elelem/tool.rb
@@ -7,11 +7,12 @@ module Elelem
     def initialize(name, description:, params: {}, required: [], aliases: [], &fn)
       @name = name
       @description = description
-      @params = params
-      @required = required
-      @aliases = aliases
+      @params = params.freeze
+      @required = required.freeze
+      @aliases = aliases.freeze
       @fn = fn
-      @schema = JSONSchemer.schema(schema_hash)
+      @schema_hash = { type: "object", properties: @params, required: @required }.freeze
+      @schema = JSONSchemer.schema(@schema_hash)
     end
 
     def call(args)
@@ -30,19 +31,9 @@ module Elelem
         function: {
           name: name,
           description: description,
-          parameters: schema_hash
+          parameters: @schema_hash
         }
       }
     end
-
-    private
-
-    def schema_hash
-      {
-        type: "object",
-        properties: params,
-        required: required
-      }
-    end
   end
 end
lib/elelem/toolbox.rb
@@ -16,17 +16,19 @@ module Elelem
       tool.aliases.each { |a| @aliases[a] = name }
     end
 
-    def before(tool_name, &block)
+    def before(tool_name = :*, &block)
       @hooks[:before][tool_name] << block
     end
 
-    def after(tool_name, &block)
+    def after(tool_name = :*, &block)
       @hooks[:after][tool_name] << block
     end
 
     def header(name, args, state: "+")
-      name = tool_for(name)&.name || "#{name}?"
-      "\n#{state} #{name}(#{args})"
+      tool = tool_for(name)
+      color = tool ? "36" : "33"
+      name = tool&.name || "#{name}?"
+      "\n#{state} \e[#{color}m#{name}\e[0m(#{args})"
     end
 
     def run(name, args)
@@ -36,14 +38,21 @@ module Elelem
       errors = tool.validate(args)
       return failure(error: errors.join(", ")) if errors.any?
 
+      @hooks[:before][:*].each { |h| h.call(args, tool_name: tool.name) }
       @hooks[:before][tool.name].each { |h| h.call(args) }
       result = tool.call(args)
+      @hooks[:after][:*].each { |h| h.call(args, result, tool_name: tool.name) }
       @hooks[:after][tool.name].each { |h| h.call(args, result) }
       result[:error] ? failure(result) : success(result)
     rescue => e
       failure(error: e.message, name: name, args: args)
     end
 
+    def exec(*args)
+      command = args.flatten.map { |a| Shellwords.escape(a.to_s) }.join(" ")
+      run("execute", { "command" => command })
+    end
+
     def to_a
       tools.values.map(&:to_h)
     end
lib/elelem/version.rb
@@ -1,5 +1,5 @@
 # frozen_string_literal: true
 
 module Elelem
-  VERSION = "0.9.2"
+  VERSION = "0.10.0"
 end
\ No newline at end of file
lib/elelem.rb
@@ -1,19 +1,30 @@
 # frozen_string_literal: true
 
+require "base64"
 require "date"
+require "digest"
 require "erb"
 require "fileutils"
 require "json"
 require "json_schemer"
+require "logger"
+require "net/hippie"
 require "open3"
+require "optparse"
 require "pathname"
 require "reline"
+require "securerandom"
 require "stringio"
 require "tempfile"
+require "uri"
+require "webrick"
 
 require_relative "elelem/agent"
+require_relative "elelem/commands"
+require_relative "elelem/conversation"
 require_relative "elelem/mcp"
 require_relative "elelem/net"
+require_relative "elelem/permissions"
 require_relative "elelem/plugins"
 require_relative "elelem/system_prompt"
 require_relative "elelem/terminal"
@@ -37,14 +48,94 @@ module Elelem
   end
 
   def self.start(client, toolbox: Toolbox.new)
-    Plugins.setup!(toolbox)
-    Agent.new(client, toolbox).repl
+    agent = Agent.new(client, toolbox: toolbox)
+    Plugins.setup!(agent)
+    agent.terminal = Terminal.new(commands: agent.commands.names)
+    agent.repl
   end
 
   def self.ask(client, prompt, toolbox: Toolbox.new)
-    Plugins.setup!(toolbox)
-    agent = Agent.new(client, toolbox, terminal: Terminal.new(quiet: true))
+    agent = Agent.new(client, toolbox: toolbox, terminal: Terminal.new(quiet: true))
+    Plugins.setup!(agent)
     agent.turn(prompt)
-    agent.history.last[:content]
+    agent.conversation.last[:content]
+  end
+
+  class CLI
+    MODELS = {
+      "ollama" => "gpt-oss:latest",
+      "anthropic" => "claude-opus-4-5-20250514",
+      "vertex" => "claude-opus-4-5@20251101",
+      "openai" => "gpt-4o"
+    }.freeze
+
+    PROVIDERS = {
+      "ollama" => ->(model) { Elelem::Net::Ollama.new(model: model, host: ENV.fetch("OLLAMA_HOST", "localhost:11434")) },
+      "anthropic" => ->(model) { Elelem::Net::Claude.anthropic(model: model, api_key: ENV.fetch("ANTHROPIC_API_KEY")) },
+      "vertex" => ->(model) { Elelem::Net::Claude.vertex(model: model, project: ENV.fetch("GOOGLE_CLOUD_PROJECT"), region: ENV.fetch("GOOGLE_CLOUD_REGION", "us-east5")) },
+      "openai" => ->(model) { Elelem::Net::OpenAI.new(model: model, api_key: ENV.fetch("OPENAI_API_KEY")) }
+    }.freeze
+
+    def initialize(args)
+      @provider = "ollama"
+      @model = nil
+      @args = parse(args)
+    end
+
+    def run
+      command = @args.shift || "chat"
+      send(command.tr("-", "_"))
+    rescue NoMethodError
+      abort "Unknown command: #{command}"
+    end
+
+    private
+
+    def parse(args)
+      @parser = OptionParser.new do |o|
+        o.banner = "Usage: elelem [command] [options] [args]"
+        o.separator "\nCommands:"
+        o.separator "  chat              Interactive REPL (default)"
+        o.separator "  ask <prompt>      One-shot query (reads stdin if piped)"
+        o.separator "  files             Output files as XML (no options)"
+        o.separator "  help              Show this help"
+        o.separator "\nOptions:"
+        o.on("-p", "--provider NAME", "ollama, anthropic, vertex, openai") { |p| @provider = p }
+        o.on("-m", "--model NAME", "Override default model") { |m| @model = m }
+        o.on("-h", "--help") { puts o; exit }
+      end
+      @parser.parse!(args)
+    end
+
+    def help
+      puts @parser
+    end
+
+    def client
+      model = @model || MODELS.fetch(@provider)
+      PROVIDERS.fetch(@provider).call(model)
+    end
+
+    def chat
+      Elelem.start(client)
+    end
+
+    def ask
+      abort "Usage: elelem ask <prompt>" if @args.empty?
+      prompt = @args.join(" ")
+      prompt = "#{prompt}\n\n```\n#{$stdin.read}\n```" if $stdin.stat.pipe?
+      Elelem::Terminal.new.markdown Elelem.ask(client, prompt)
+    end
+
+    def files
+      files = $stdin.stat.pipe? ? $stdin.readlines : `git ls-files`.lines
+      puts "<documents>"
+      files.each_with_index do |line, i|
+        path = line.strip
+        next if path.empty? || !File.file?(path)
+        puts %Q{<document index="#{i + 1}"><source>#{path}</source><content><![CDATA[#{File.read(path)}]]></content></document>}
+      end
+      puts "</documents>"
+    end
   end
 end
spec/elelem/mcp/http_server_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::MCP::HttpServer do
+  let(:url) { "https://mcp.example.com/sse" }
+  let(:http) { double("http") }
+
+  describe "#parse_sse" do
+    subject { described_class.allocate }
+
+    it "parses single SSE event" do
+      response = double
+      allow(response).to receive(:read_body).and_yield("data: {\"result\": \"ok\"}\n\n")
+
+      result = subject.send(:parse_sse, response)
+      expect(result).to eq({ "result" => "ok" })
+    end
+
+    it "parses chunked SSE events" do
+      response = double
+      chunks = ["data: {\"id\"", ": 1}\n\ndata: {\"id\": 2}\n\n"]
+      allow(response).to receive(:read_body) do |&block|
+        chunks.each { |c| block.call(c) }
+      end
+
+      result = subject.send(:parse_sse, response)
+      expect(result).to eq({ "id" => 2 })
+    end
+
+    it "ignores non-data lines" do
+      response = double
+      allow(response).to receive(:read_body).and_yield("event: message\ndata: {\"value\": 42}\n\n")
+
+      result = subject.send(:parse_sse, response)
+      expect(result).to eq({ "value" => 42 })
+    end
+
+    it "returns last event when multiple present" do
+      response = double
+      allow(response).to receive(:read_body).and_yield("data: {\"n\": 1}\n\ndata: {\"n\": 2}\n\n")
+
+      result = subject.send(:parse_sse, response)
+      expect(result).to eq({ "n" => 2 })
+    end
+  end
+
+  describe "#parse_response" do
+    subject { described_class.allocate }
+
+    it "parses JSON response" do
+      response = double(content_type: "application/json", body: '{"tools": []}')
+      result = subject.send(:parse_response, response)
+      expect(result).to eq({ "tools" => [] })
+    end
+
+    it "parses SSE response" do
+      response = double(content_type: "text/event-stream")
+      allow(response).to receive(:read_body).and_yield("data: {\"ok\": true}\n\n")
+
+      result = subject.send(:parse_response, response)
+      expect(result).to eq({ "ok" => true })
+    end
+
+    it "returns nil for empty body" do
+      response = double(content_type: "application/json", body: "")
+      result = subject.send(:parse_response, response)
+      expect(result).to be_nil
+    end
+  end
+
+  describe "#request_headers" do
+    subject do
+      server = described_class.allocate
+      server.instance_variable_set(:@headers, {})
+      server.instance_variable_set(:@session_id, nil)
+      server.instance_variable_set(:@access_token, nil)
+      server
+    end
+
+    it "includes Accept header" do
+      headers = subject.send(:request_headers)
+      expect(headers["Accept"]).to eq("application/json, text/event-stream")
+    end
+
+    it "includes session ID when set" do
+      subject.instance_variable_set(:@session_id, "abc123")
+      headers = subject.send(:request_headers)
+      expect(headers["Mcp-Session-Id"]).to eq("abc123")
+    end
+
+    it "includes authorization when access_token set" do
+      subject.instance_variable_set(:@access_token, "token123")
+      headers = subject.send(:request_headers)
+      expect(headers["Authorization"]).to eq("Bearer token123")
+    end
+  end
+end
spec/elelem/mcp/oauth_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::MCP::OAuth do
+  let(:resource_url) { "https://mcp.example.com/sse" }
+  let(:http) { double("http") }
+  let(:storage) { instance_double(Elelem::MCP::TokenStorage) }
+
+  subject { described_class.new(resource_url, http: http) }
+
+  before do
+    allow(Elelem::MCP::TokenStorage).to receive(:new).and_return(storage)
+  end
+
+  describe "#token" do
+    context "with valid cached token" do
+      it "returns cached access_token" do
+        allow(storage).to receive(:load).with(resource_url).and_return({
+          access_token: "cached_token",
+          expires_at: Time.now.to_i + 3600
+        })
+
+        expect(subject.token).to eq("cached_token")
+      end
+    end
+
+    context "with expired token and refresh_token" do
+      let(:auth_metadata) do
+        {
+          "authorization_endpoint" => "https://auth.example.com/authorize",
+          "token_endpoint" => "https://auth.example.com/token",
+          "registration_endpoint" => "https://auth.example.com/register"
+        }
+      end
+
+      it "refreshes using refresh_token" do
+        allow(storage).to receive(:load).with(resource_url).and_return({
+          access_token: "old_token",
+          refresh_token: "refresh_abc",
+          expires_at: Time.now.to_i - 100
+        })
+
+        allow(storage).to receive(:load_client).with(resource_url).and_return({
+          client_id: "elelem-123"
+        })
+
+        resource_response = double(body: { "authorization_servers" => ["https://auth.example.com"] }.to_json)
+        auth_response = double(body: auth_metadata.to_json)
+        token_response = double(body: { "access_token" => "new_token", "expires_in" => 3600 }.to_json)
+
+        allow(http).to receive(:get).with("https://mcp.example.com/.well-known/oauth-protected-resource").and_yield(resource_response)
+        allow(http).to receive(:get).with("https://auth.example.com/.well-known/oauth-authorization-server").and_yield(auth_response)
+        allow(http).to receive(:post).and_yield(token_response)
+        allow(storage).to receive(:save)
+
+        expect(subject.token).to eq("new_token")
+      end
+    end
+  end
+
+  describe "PKCE generation" do
+    it "generates valid verifier and challenge" do
+      verifier, challenge = subject.send(:generate_pkce)
+
+      expect(verifier.length).to be >= 43
+      expect(challenge.length).to be >= 43
+      expect(challenge).not_to include("+", "/", "=")
+
+      expected_challenge = Base64.urlsafe_encode64(
+        Digest::SHA256.digest(verifier),
+        padding: false
+      )
+      expect(challenge).to eq(expected_challenge)
+    end
+  end
+
+  describe "expiration check" do
+    it "considers token expired when within 60 seconds of expiry" do
+      stored = { expires_at: Time.now.to_i + 30 }
+      expect(subject.send(:expired?, stored)).to be true
+    end
+
+    it "considers token valid when more than 60 seconds remain" do
+      stored = { expires_at: Time.now.to_i + 120 }
+      expect(subject.send(:expired?, stored)).to be false
+    end
+
+    it "considers token valid when no expires_at" do
+      stored = { expires_at: nil }
+      expect(subject.send(:expired?, stored)).to be false
+    end
+  end
+end
spec/elelem/mcp/token_storage_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::MCP::TokenStorage do
+  subject { described_class.new }
+
+  let(:resource_url) { "https://example.com/mcp" }
+  let(:token_dir) { File.expand_path("~/.config/elelem/tokens") }
+
+  after do
+    Dir.glob(File.join(token_dir, "*.json")).each { |f| File.delete(f) rescue nil }
+  end
+
+  describe "#save and #load" do
+    it "stores and retrieves tokens" do
+      subject.save(resource_url, access_token: "abc123", refresh_token: "refresh456", expires_in: 3600)
+
+      stored = subject.load(resource_url)
+      expect(stored[:access_token]).to eq("abc123")
+      expect(stored[:refresh_token]).to eq("refresh456")
+      expect(stored[:expires_at]).to be > Time.now.to_i
+    end
+
+    it "returns nil for unknown resource" do
+      expect(subject.load("https://unknown.com")).to be_nil
+    end
+
+    it "handles missing refresh_token" do
+      subject.save(resource_url, access_token: "abc123")
+
+      stored = subject.load(resource_url)
+      expect(stored[:access_token]).to eq("abc123")
+      expect(stored[:refresh_token]).to be_nil
+    end
+  end
+
+  describe "#save_client and #load_client" do
+    it "stores and retrieves client registration" do
+      client_data = { "client_id" => "elelem-123", "client_secret" => nil }
+      subject.save_client(resource_url, client_data)
+
+      stored = subject.load_client(resource_url)
+      expect(stored[:client_id]).to eq("elelem-123")
+    end
+
+    it "returns nil for unknown client" do
+      expect(subject.load_client("https://unknown.com")).to be_nil
+    end
+  end
+
+  describe "file permissions" do
+    it "creates token files with 0600 permissions" do
+      subject.save(resource_url, access_token: "secret")
+
+      files = Dir.glob(File.join(token_dir, "*.json"))
+      expect(files).not_to be_empty
+      files.each do |f|
+        mode = File.stat(f).mode & 0o777
+        expect(mode).to eq(0o600)
+      end
+    end
+  end
+end
spec/elelem/commands_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Commands do
+  subject { described_class.new }
+
+  describe "#register" do
+    it "registers a command with handler" do
+      called = false
+      subject.register("test") { called = true }
+      subject.run("test")
+      expect(called).to be true
+    end
+
+    it "stores description" do
+      subject.register("test", description: "Test command") { }
+      expect(subject.include?("test")).to be true
+    end
+  end
+
+  describe "#run" do
+    it "returns true when command exists" do
+      subject.register("test") { }
+      expect(subject.run("test")).to be true
+    end
+
+    it "returns false when command does not exist" do
+      expect(subject.run("nonexistent")).to be false
+    end
+  end
+
+  describe "#names" do
+    it "returns command names with slash prefix" do
+      subject.register("exit") { }
+      subject.register("help") { }
+      expect(subject.names).to contain_exactly("/exit", "/help")
+    end
+  end
+
+  describe "#include?" do
+    it "returns true for registered commands" do
+      subject.register("test") { }
+      expect(subject.include?("test")).to be true
+    end
+
+    it "returns false for unregistered commands" do
+      expect(subject.include?("test")).to be false
+    end
+  end
+end
spec/elelem/conversation_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Conversation do
+  subject { described_class.new }
+
+  describe "#add" do
+    it "appends messages" do
+      subject.add(role: "user", content: "hello")
+      subject.add(role: "assistant", content: "hi")
+      expect(subject.length).to eq(2)
+    end
+
+    it "validates role" do
+      expect { subject.add(role: "invalid", content: "test") }.to raise_error(ArgumentError, /invalid role/)
+    end
+
+    it "accepts user role" do
+      expect { subject.add(role: "user", content: "test") }.not_to raise_error
+    end
+
+    it "accepts assistant role" do
+      expect { subject.add(role: "assistant", content: "test") }.not_to raise_error
+    end
+
+    it "accepts tool role" do
+      expect { subject.add(role: "tool", content: "test") }.not_to raise_error
+    end
+  end
+
+  describe "#last" do
+    it "returns the last message" do
+      subject.add(role: "user", content: "hello")
+      subject.add(role: "assistant", content: "hi")
+      expect(subject.last[:content]).to eq("hi")
+    end
+  end
+
+  describe "#clear!" do
+    it "removes all messages" do
+      subject.add(role: "user", content: "hello")
+      subject.clear!
+      expect(subject.length).to eq(0)
+    end
+  end
+
+  describe "#to_a" do
+    it "returns messages without system prompt by default" do
+      subject.add(role: "user", content: "hello")
+      expect(subject.to_a).to eq([{ role: "user", content: "hello" }])
+    end
+
+    it "includes system prompt when provided" do
+      subject.add(role: "user", content: "hello")
+      result = subject.to_a(system_prompt: "You are helpful.")
+      expect(result.first).to eq({ role: "system", content: "You are helpful." })
+      expect(result.last).to eq({ role: "user", content: "hello" })
+    end
+  end
+end
spec/elelem/permissions_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Permissions do
+  subject { described_class.new }
+
+  let(:terminal) { double(ask: nil) }
+
+  describe "#check" do
+    context "with default allow policies" do
+      it "allows read without prompting" do
+        expect(subject.check("read", {}, terminal: terminal)).to be true
+        expect(terminal).not_to have_received(:ask)
+      end
+    end
+
+    context "with deny policy" do
+      it "raises an error" do
+        permissions = described_class.new
+        permissions.instance_variable_set(:@rules, { write: :deny })
+        expect { permissions.check("write", {}, terminal: terminal) }.to raise_error(/Permission denied/)
+      end
+    end
+
+    context "with ask policy in non-TTY mode" do
+      before { allow($stdin).to receive(:tty?).and_return(false) }
+
+      it "returns true without prompting" do
+        expect(subject.check("execute", {}, terminal: terminal)).to be true
+      end
+    end
+  end
+end
spec/elelem/toolbox_spec.rb
@@ -48,4 +48,55 @@ RSpec.describe Elelem::Toolbox do
       expect(result[:error]).to include("unknown tool")
     end
   end
+
+  describe "#exec" do
+    it "escapes arguments and runs execute" do
+      result = subject.exec("echo", "hello world")
+      expect(result[:output]).to include("hello world")
+    end
+
+    it "handles arrays of arguments" do
+      result = subject.exec("echo", ["a", "b"])
+      expect(result[:output]).to include("a")
+    end
+  end
+
+  describe "hooks" do
+    it "runs tool-specific before hooks" do
+      called = false
+      subject.before("read") { |_args| called = true }
+      subject.run("read", { "path" => __FILE__ })
+      expect(called).to be true
+    end
+
+    it "runs tool-specific after hooks" do
+      result_seen = nil
+      subject.after("read") { |_args, result| result_seen = result }
+      subject.run("read", { "path" => __FILE__ })
+      expect(result_seen[:content]).to include("RSpec.describe")
+    end
+
+    it "runs global before hooks with tool_name" do
+      tool_names = []
+      subject.before { |_args, tool_name:| tool_names << tool_name }
+      subject.run("read", { "path" => __FILE__ })
+      subject.run("write", { "path" => "/dev/null", "content" => "" })
+      expect(tool_names).to eq(["read", "write"])
+    end
+
+    it "runs global after hooks with tool_name" do
+      tool_names = []
+      subject.after { |_args, _result, tool_name:| tool_names << tool_name }
+      subject.run("read", { "path" => __FILE__ })
+      expect(tool_names).to eq(["read"])
+    end
+
+    it "runs global hooks before tool-specific hooks" do
+      order = []
+      subject.before { |_args, tool_name:| order << :global }
+      subject.before("read") { |_args| order << :specific }
+      subject.run("read", { "path" => __FILE__ })
+      expect(order).to eq([:global, :specific])
+    end
+  end
 end
.gitignore
@@ -13,7 +13,6 @@
 *.log
 target/
 *.gem
-.mcp.json
 
 # rspec failure tracking
 .rspec_status
CHANGELOG.md
@@ -1,3 +1,32 @@
+## [0.10.0] - 2026-01-27
+
+### Added
+- **Async MCP loading** for faster startup - tools load in background thread
+- **HTTP MCP servers** with SSE support and session management
+- **OAuth authentication** for MCP servers with PKCE, automatic token refresh
+- **Global hooks** - `toolbox.before`/`toolbox.after` without tool name applies to all tools
+- **`/context` improvements**: `/context <n>` to view entry, `/context json` for full dump
+- **ast-grep (`sg`) support** for building repo maps - faster and more accurate than ctags
+- **New tools**: `glob`, `grep`, `list`, `git`, `task`, `/tools` command
+- **Permissions system** (`lib/elelem/permissions.rb`) for tool access control
+- **OpenAI reasoning mode** - enables `Reasoning: high` for o-series models
+- **Test coverage** for OAuth, token storage, HTTP MCP, SSE parsing, global hooks
+
+### Changed
+- **BREAKING: Plugin API** - plugins now receive `agent` instead of `toolbox`
+  - Old: `Elelem::Plugins.register(:name) { |toolbox| toolbox.add(...) }`
+  - New: `Elelem::Plugins.register(:name) { |agent| agent.toolbox.add(...) }`
+  - Plugins can now access `agent.terminal`, `agent.commands`, `agent.conversation`
+- Extracted `Conversation` class from `Agent` for better separation of concerns
+- Extracted `Commands` class for slash command handling
+- Refactored LLM fetch interface to emit separate events for thinking/content/tool_calls
+- Simplified system prompt with inline ERB template
+- Renamed confirm plugin to `zz_confirm` to ensure it loads last
+- MCP logs now write to `~/.elelem/mcp.log` instead of working directory
+- Tool schema now frozen to prevent mutation
+- Uses `Open3.capture2` instead of backticks for thread safety
+- Improved ANSI escape sequence stripping in `/shell` transcripts
+
 ## [0.9.2] - 2026-01-22
 
 ### Fixed
elelem.gemspec
@@ -27,22 +27,34 @@ Gem::Specification.new do |spec|
     "exe/elelem",
     "lib/elelem.rb",
     "lib/elelem/agent.rb",
+    "lib/elelem/commands.rb",
+    "lib/elelem/conversation.rb",
     "lib/elelem/mcp.rb",
+    "lib/elelem/mcp/oauth.rb",
+    "lib/elelem/mcp/token_storage.rb",
     "lib/elelem/net.rb",
     "lib/elelem/net/claude.rb",
     "lib/elelem/net/ollama.rb",
     "lib/elelem/net/openai.rb",
+    "lib/elelem/permissions.rb",
     "lib/elelem/plugins.rb",
-    "lib/elelem/plugins/confirm.rb",
+    "lib/elelem/plugins/builtins.rb",
     "lib/elelem/plugins/edit.rb",
     "lib/elelem/plugins/eval.rb",
     "lib/elelem/plugins/execute.rb",
+    "lib/elelem/plugins/git.rb",
+    "lib/elelem/plugins/glob.rb",
+    "lib/elelem/plugins/grep.rb",
+    "lib/elelem/plugins/list.rb",
     "lib/elelem/plugins/mcp.rb",
+    "lib/elelem/plugins/permissions.json",
     "lib/elelem/plugins/read.rb",
+    "lib/elelem/plugins/task.rb",
+    "lib/elelem/plugins/tools.rb",
     "lib/elelem/plugins/verify.rb",
     "lib/elelem/plugins/write.rb",
+    "lib/elelem/plugins/zz_confirm.rb",
     "lib/elelem/system_prompt.rb",
-    "lib/elelem/templates/system_prompt.erb",
     "lib/elelem/terminal.rb",
     "lib/elelem/tool.rb",
     "lib/elelem/toolbox.rb",
@@ -52,17 +64,23 @@ Gem::Specification.new do |spec|
   spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
   spec.require_paths = ["lib"]
 
+  spec.add_dependency "base64", "~> 0.1"
   spec.add_dependency "date", "~> 3.0"
+  spec.add_dependency "digest", "~> 3.0"
   spec.add_dependency "erb", "~> 6.0"
   spec.add_dependency "fileutils", "~> 1.0"
   spec.add_dependency "json", "~> 2.0"
   spec.add_dependency "json_schemer", "~> 2.0"
+  spec.add_dependency "logger", "~> 1.0"
   spec.add_dependency "net-hippie", "~> 1.0"
   spec.add_dependency "open3", "~> 0.1"
   spec.add_dependency "optparse", "~> 0.1"
   spec.add_dependency "pathname", "~> 0.1"
   spec.add_dependency "reline", "~> 0.6"
+  spec.add_dependency "securerandom", "~> 0.1"
+  spec.add_dependency "shellwords", "~> 0.2"
   spec.add_dependency "stringio", "~> 3.0"
   spec.add_dependency "tempfile", "~> 0.3"
   spec.add_dependency "uri", "~> 1.0"
+  spec.add_dependency "webrick", "~> 1.9"
 end
Gemfile.lock
@@ -1,20 +1,26 @@
 PATH
   remote: .
   specs:
-    elelem (0.9.2)
+    elelem (0.10.0)
+      base64 (~> 0.1)
       date (~> 3.0)
+      digest (~> 3.0)
       erb (~> 6.0)
       fileutils (~> 1.0)
       json (~> 2.0)
       json_schemer (~> 2.0)
+      logger (~> 1.0)
       net-hippie (~> 1.0)
       open3 (~> 0.1)
       optparse (~> 0.1)
       pathname (~> 0.1)
       reline (~> 0.6)
+      securerandom (~> 0.1)
+      shellwords (~> 0.2)
       stringio (~> 3.0)
       tempfile (~> 0.3)
       uri (~> 1.0)
+      webrick (~> 1.9)
 
 GEM
   remote: https://rubygems.org/
@@ -23,6 +29,7 @@ GEM
     bigdecimal (4.0.1)
     date (3.5.1)
     diff-lcs (1.6.2)
+    digest (3.2.1)
     erb (6.0.1)
     fileutils (1.8.0)
     hana (1.3.7)
@@ -77,11 +84,14 @@ GEM
       diff-lcs (>= 1.2.0, < 2.0)
       rspec-support (~> 3.13.0)
     rspec-support (3.13.6)
+    securerandom (0.4.1)
+    shellwords (0.2.2)
     simpleidn (0.2.3)
     stringio (3.2.0)
     tempfile (0.3.1)
     tsort (0.2.0)
     uri (1.1.1)
+    webrick (1.9.2)
 
 PLATFORMS
   ruby
Rakefile
@@ -5,15 +5,4 @@ require "rspec/core/rake_task"
 
 RSpec::Core::RakeTask.new(:spec)
 
-task :files do
-  IO.popen(%w[git ls-files], chdir: __dir__, err: IO::NULL) do |ls|
-    ls.readlines.each do |f|
-      next if f.start_with?(*%w[bin/ spec/ pkg/ .git .rspec Gemfile Rakefile])
-      next if f.strip.end_with?(*%w[.toml .txt .md])
-
-      puts f
-    end
-  end
-end
-
 task default: %i[spec]
README.md
@@ -31,17 +31,18 @@ Elelem relies on several external tools. Install the ones you need:
 
 | Tool | Purpose | Install |
 |------|---------|---------|
-| [Ollama](https://ollama.ai/) | Default LLM provider | https://ollama.ai/download |
-| [glow](https://github.com/charmbracelet/glow) | Markdown rendering | `brew install glow` / `go install github.com/charmbracelet/glow@latest` |
+| [ast-grep](https://ast-grep.github.io/) | Structural search (`sg`) | `brew install ast-grep` / `cargo install ast-grep` |
 | [ctags](https://ctags.io/) | Repo map generation | `brew install universal-ctags` / `apt install universal-ctags` |
-| [ripgrep](https://github.com/BurntSushi/ripgrep) | Text search (`rg`) | `brew install ripgrep` / `apt install ripgrep` |
 | [fd](https://github.com/sharkdp/fd) | File discovery | `brew install fd` / `apt install fd-find` |
-| [ast-grep](https://ast-grep.github.io/) | Structural search (`sg`) | `brew install ast-grep` / `cargo install ast-grep` |
-| [Git](https://git-scm.com/) | Version control | `brew install git` / `apt install git` |
+| [git](https://git-scm.com/) | Version control | `brew install git` / `apt install git` |
+| [glow](https://github.com/charmbracelet/glow) | Markdown rendering | `brew install glow` / `go install github.com/charmbracelet/glow@latest` |
+| [jq](https://jqlang.github.io/jq/) | JSON processing | `brew install jq` / `apt install jq` |
+| [ollama](https://ollama.ai/) | Default LLM provider | https://ollama.ai/download |
+| [ripgrep](https://github.com/BurntSushi/ripgrep) | Text search (`rg`) | `brew install ripgrep` / `apt install ripgrep` |
 
 **Required:** Git, Ollama (or another LLM provider)
 
-**Recommended:** glow, ctags, ripgrep, fd
+**Recommended:** glow, jq, ctags, ripgrep, fd
 
 **Optional:** ast-grep (for structural code search)
 
@@ -128,18 +129,94 @@ Each provider reads its configuration from environment variables:
 * **Conversation History** – persists across turns; can be cleared.
 * **Context Dump** – `/context` shows the current conversation state.
 
-## Toolbox Overview
-
-The `Toolbox` class is defined in `lib/elelem/toolbox.rb`. It supplies
-three tools, each represented by a JSON schema that the LLM can call.
+## Tools
+
+Built-in tools available to the LLM:
+
+| Tool      | Purpose                    | Parameters                |
+| --------- | -------------------------- | ------------------------- |
+| `read`    | Read file contents         | `path`                    |
+| `write`   | Write file                 | `path`, `content`         |
+| `edit`    | Replace text in file       | `path`, `old`, `new`      |
+| `execute` | Run shell command          | `command`                 |
+| `eval`    | Execute Ruby code          | `ruby`                    |
+| `glob`    | Find files by pattern      | `pattern`, `path`         |
+| `grep`    | Search file contents       | `pattern`, `path`, `glob` |
+| `list`    | List directory             | `path`, `recursive`       |
+| `git`     | Run git command            | `command`, `args`         |
+| `task`    | Delegate to sub-agent      | `prompt`                  |
+| `verify`  | Check syntax and run tests | `path`                    |
+
+Aliases: `bash`, `sh`, `exec` → `execute`; `open` → `read`; `ls` → `list`
+
+## Plugins
+
+Plugins extend elelem with custom tools and commands. They are loaded from:
+- `lib/elelem/plugins/` (built-in)
+- `~/.elelem/plugins/` (user global)
+- `.elelem/plugins/` (project local)
+
+### Writing a Plugin
+
+```ruby
+# ~/.elelem/plugins/hello.rb
+Elelem::Plugins.register(:hello) do |agent|
+  # Add a tool
+  agent.toolbox.add("hello",
+    description: "Say hello",
+    params: { name: { type: "string" } },
+    required: ["name"]
+  ) do |args|
+    { message: "Hello, #{args["name"]}!" }
+  end
+
+  # Add a command
+  agent.commands.register("greet", description: "Greet the user") do
+    agent.terminal.say "Hello!"
+  end
+
+  # Add hooks
+  agent.toolbox.before("execute") { |args| puts "Running: #{args["command"]}" }
+  agent.toolbox.after("execute") { |args, result| puts "Exit: #{result[:exit_status]}" }
+
+  # Global hook (runs for all tools)
+  agent.toolbox.before { |args, tool_name:| puts "Calling #{tool_name}" }
+end
+```
 
-| Tool      | Purpose            | Parameters         |
-| --------- | ------------------ | ------------------ |
-| `read`    | Read file contents | `path`             |
-| `write`   | Write file         | `path`, `content`  |
-| `execute` | Run shell command  | `command`          |
+### Plugin API
+
+Plugins receive an `agent` object with access to:
+- `agent.toolbox` - add tools, register hooks
+- `agent.terminal` - output to the user (`say`, `ask`, `markdown`)
+- `agent.commands` - register slash commands
+- `agent.conversation` - access message history
+- `agent.client` - the LLM client
+- `agent.fork(system_prompt:)` - create a sub-agent
+
+## MCP Configuration
+
+Configure MCP servers in `~/.elelem/mcp.json` or `.elelem/mcp.json`:
+
+```json
+{
+  "mcpServers": {
+    "gitlab": {
+      "command": "npx",
+      "args": ["-y", "@anthropics/gitlab-mcp"],
+      "env": {
+        "GITLAB_TOKEN": "${GITLAB_TOKEN}"
+      }
+    },
+    "remote": {
+      "type": "http",
+      "url": "https://mcp.example.com/sse"
+    }
+  }
+}
+```
 
-Aliases: `bash`, `sh`, `exec` → `execute`; `open` → `read`
+HTTP servers support OAuth authentication automatically.
 
 ## Known Limitations