-- repl.lua — readline loop, input dispatch, prompt rendering.
-- Wires ffi/readline + router + executor + broker + context + renderer.
-- See docs/PHASE0.md §5 (dispatch), §9 (prompt + readline).

local rl       = require("ffi.readline")
local router   = require("router")
local executor = require("executor")
local broker   = require("broker")
local renderer = require("renderer")
local Context  = require("context")
local history  = require("history")
local mcp      = require("mcp")
local sicmod   = require("sic")
local safety   = require("safety")
local json     = require("dkjson")

-- ---------------------------------------------------------------- @-mentions (issue #7)
-- Triggered when "@" follows start-of-string or whitespace (avoids
-- false positives on email addresses like user@example.com). Path
-- runs until next whitespace. The mention is replaced by a fenced
-- code block carrying the file contents, language-tagged by extension.
-- Files over MENTION_MAX_BYTES are truncated head+tail with a marker.
local MENTION_MAX_BYTES = 32 * 1024
local MENTION_HEAD      = 16 * 1024
local MENTION_TAIL      = 8 * 1024
local LANG_BY_EXT = {
    lua = "lua", py = "python", js = "javascript", ts = "typescript",
    sh = "bash", c = "c", h = "c", cc = "cpp", cpp = "cpp", hpp = "cpp",
    rs = "rust", go = "go", java = "java", rb = "ruby", md = "markdown",
    json = "json", yaml = "yaml", yml = "yaml", toml = "toml",
    html = "html", css = "css", sql = "sql", xml = "xml",
}
local function _lang_of(path)
    local ext = path:match("%.([%w]+)$")
    return ext and LANG_BY_EXT[ext:lower()] or ""
end
local function _read_truncated(path)
    local f = io.open(path, "rb")
    if not f then return nil end
    local content = f:read("*a") or ""
    f:close()
    if #content <= MENTION_MAX_BYTES then return content, false end
    local head = content:sub(1, MENTION_HEAD)
    local tail = content:sub(#content - MENTION_TAIL + 1)
    return head
        .. ("\n... [%d bytes elided] ...\n"):format(#content - MENTION_HEAD - MENTION_TAIL)
        .. tail, true
end

-- ---------------------------------------------------------------- shared shell helpers
-- Lifted from M.run closure scope so expand_mentions (module-scope) can also
-- use them for the @<r1>..<r2> diff-retry path. Same single source of truth
-- for the B1 git invocation prefix; commits #3 and #4 both call _git_clean_cmd.
local function _shq(s) return "'" .. (s or ""):gsub("'", [['\'']]) .. "'" end
local function _git_clean_cmd(subcmd_and_args)
    return "git --no-pager -c color.ui=never " .. subcmd_and_args
end

local function expand_mentions(line, on_status)
    -- Walk the line; for each "@<path>" preceded by SOL or whitespace,
    -- attempt to read and substitute. Missing files leave the literal
    -- token in place + emit a status warning.
    local out, i = {}, 1
    while i <= #line do
        local at_start = (i == 1) or line:sub(i - 1, i - 1):match("%s") ~= nil
        if at_start and line:sub(i, i) == "@" then
            local path_end = line:find("%s", i + 1) or (#line + 1)
            local raw = line:sub(i + 1, path_end - 1)
            -- Peel one or more trailing punctuation chars (,.;:?!) if the
            -- full path doesn't resolve — handles natural prose like
            -- "look at @README.md, then..." or "@foo.lua." at sentence end.
            local path, trail = raw, ""
            while #path > 0 do
                local f = io.open(path, "rb")
                if f then f:close(); break end
                local last = path:sub(-1)
                if last:match("[%.,;:?!)]") then
                    trail = last .. trail
                    path  = path:sub(1, -2)
                else
                    break
                end
            end
            if path ~= "" then
                local content, truncated = _read_truncated(path)
                local lang_override = nil
                -- Phase 6 / A6: tiered resolution — if path lookup
                -- failed AND token contains "..", try as a git diff
                -- ref-range. `@HEAD~1..HEAD` and `@origin/main..feature`
                -- both fall through to this branch when no such file
                -- exists. `@../sibling.txt` resolves as path first
                -- and never reaches this retry.
                if not content and path:find("..", 1, true) then
                    local r1, r2 = path:match("^(.-)%.%.(.+)$")
                    if r1 and r2 and r1 ~= "" and r2 ~= "" then
                        local out_diff, code = executor.exec(
                            _git_clean_cmd(("diff %s..%s 2>/dev/null")
                                :format(_shq(r1), _shq(r2))))
                        if code == 0 and out_diff and out_diff:match("%S") then
                            content       = out_diff
                            lang_override = "diff"
                        end
                    end
                end
                if content then
                    local lang = lang_override or _lang_of(path)
                    if on_status then
                        on_status(("@%s expanded (%d bytes%s)"):format(
                            path, #content,
                            truncated and ", truncated"
                            or (lang_override == "diff" and ", diff" or "")))
                    end
                    out[#out + 1] = ("```%s path=%s\n%s\n```%s"):format(
                        lang, path, content, trail)
                    i = path_end
                else
                    if on_status then
                        on_status(("@%s: not found"):format(raw))
                    end
                    out[#out + 1] = line:sub(i, path_end - 1)
                    i = path_end
                end
            else
                out[#out + 1] = "@"
                i = i + 1
            end
        else
            out[#out + 1] = line:sub(i, i)
            i = i + 1
        end
    end
    return table.concat(out)
end

local M = {}

local HELP = [[
Meta commands:
  :quit / :q              exit aish (session flushed and closed)
  :clear                  clear screen (history kept)
  :reset                  clear in-memory conversation history
  :model <name>           switch active model
  :models                 list configured models (* = active)
  :history                show conversation turns
  :exec <cmd>             force shell execution
  :ask <text>             force AI query (supports @path expansion)
  :sessions               list session log files
  :save <name>            rename current session log to <name>.jsonl
  :resume <name>          load <name>.jsonl turns into the in-memory context
  :mcp list               show connected MCP servers
  :mcp tools              list tools across all sessions
  :mcp tool <alias__name> show one tool's inputSchema
  :mcp connect <url> [a]  open an MCP session at runtime
  :mcp disconnect <alias> drop an MCP session
  :norris <goal>          launch Chuck Norris autonomous mode on <goal>
  :norris off             exit Norris mode (rare — usually 'abort' at halt)
  :plan                   toggle plan mode (CMD: lines printed, NOT executed)
  :plan on / :plan off    set plan mode explicitly
  :safety patterns        list active destructive-op patterns
  :safety check <cmd>     probe is_destructive against <cmd> without running
  :perms list             show configured permission rules (allow/confirm/deny)
  :perms check <cmd>      report which permission verdict <cmd> would receive
  :remember <text>        shortcut: :memory add fact <text>
  :memory list            show active memory items (id, ts, kind, content)
  :memory add <kind> <t>  add a memory item (kind: fact | pref | context)
  :memory forget <id>     append a tombstone for <id>
  :memory clear           forget all active items (confirms first)
  :memory inject          reload memory.jsonl into ctx (after manual edits)
  :memory summarize       LLM-extract candidate items from this session
  :route on/off           toggle auto-routing per-request (heuristic in router.lua)
  :route classes          show current class → model mapping
  :route check <text>     report which class <text> would route to (debug)
  :fallback on/off        toggle cloud retry when local transport fails
  :skills                 list user-defined skills loaded from ~/.config/aish/skills/
  :secrets [status]       show vault state, active broker redact mode (never prints values)
  :secrets check <text>   show what the active broker's scrub would do to <text>
  :every <i> <prompt>     schedule a recurring prompt (i: 30s | 5m | 2h)
  :every list             show scheduled recurring prompts
  :every cancel <id>      remove a scheduled prompt
  :bg-spawn <cmd>         start a background job directly (no AI needed)
  :bg-list                list background jobs (issued via CMD&: or :bg-spawn)
  :bg-output <id>         dump the log of a background job
  :bg-kill <id>           SIGTERM a background job
  :tree [<depth>]         scan cwd file-tree, inject as [project] block in system prompt
  :tree refresh           re-scan with last opts (or config defaults)
  :tree off               clear the [project] block
  :diff [<git-args>]      git diff <args> -> inject as [diff ...] exec_output
                          examples: :diff   :diff --cached   :diff main..feature
  :cost                   summary of session token/cost usage
  :cost detail            per-model + per-category breakdown
  :cost reset             zero the cost meter (also clears warn flags)
  :config show [full]     show config sources (user / project overlay) +
                          effective config; sensitive keys masked as (set)
  :highlight [on|off|status]
                          toggle tree-sitter syntax highlighting on assistant
                          code fences (requires external tree-sitter CLI +
                          built grammars; off by default)
  :delegate <p> <prompt>  one-shot sub-broker call to preset <p>; prints reply
  :help                   this message
]]

function M.run(config)
    assert(config and config.models, "repl.run: config.models required")
    local active_name = config.default_model or next(config.models)
    local active_cfg  = config.models[active_name]
    if not active_cfg then
        error("aish: default_model '" .. tostring(active_name)
              .. "' not found in config.models")
    end

    -- Plan mode (issue #5): when true, CMD: lines are NOT executed; they
    -- are echoed as "PLAN:" and fed back to the next-turn context as
    -- would-have-run notes so the model can iterate without side effects.
    -- Off by default; toggle with :plan / :plan on / :plan off. Orthogonal
    -- to Norris mode (Norris has its own halt protocol).
    local plan_mode = false

    -- Forward decl (issue #8): the bg spawn closure is defined deeper in
    -- M.run alongside the meta dispatch, but ask_ai needs to call it when
    -- routing CMD&: lines. Lua looks up names at call time; the closure
    -- has to exist as a local in scope BEFORE ask_ai is declared.
    local _bg_spawn

    -- Phase 7 forward decl: _record_usage is the central chokepoint
    -- for ctx:add_usage + warn-threshold check. Defined alongside
    -- call_broker below, but needs to be in lexical scope of the
    -- summarize-on-evict closure (which is built up earlier in
    -- make_summarize_fn). Same forward-declaration pattern as
    -- _bg_spawn — assign below, reference both early and late.
    local _record_usage

    -- Issue #13: secret redaction. Load vault if configured, create a
    -- session for this conversation. ctx stores PLAIN; we scrub just
    -- before broker.chat_stream and rehydrate the streamed reply for
    -- display. Tool args dispatched to MCP get rehydrated so the server
    -- sees the real values. Default mode resolution: per-broker
    -- `redact` field on the model preset → `config.secrets.default`
    -- → "vault+autodetect" if vault loaded → "off".
    local secrets = require("secrets")
    local secrets_session
    do
        local vpath = config.secrets and config.secrets.vault
        if vpath then
            -- Tilde expansion: ~ → $HOME for a common form.
            if vpath:sub(1, 2) == "~/" then
                vpath = (os.getenv("HOME") or "") .. vpath:sub(2)
            end
            local v, err = secrets.load(vpath)
            if v then
                secrets_session = secrets.make_session(v)
                renderer.status(("secrets vault loaded (%d entries)")
                                :format(#v.entries))
            else
                renderer.status(err or "secrets: load failed")
            end
        end
    end
    local function secrets_mode_for(model_cfg)
        if not secrets_session then return "off" end
        local m = (model_cfg and model_cfg.redact)
                  or (config.secrets and config.secrets.default)
        if m then return m end
        return secrets_session:has_vault() and "vault+autodetect" or "off"
    end
    -- Walk an OpenAI-shape messages array, scrub all string content per
    -- the model's redact policy. Tool-call arguments are JSON strings —
    -- scrub them too (they may carry secrets if the model put a placeholder
    -- in a tool arg and was rendered through here on a re-iteration).
    local function scrub_messages(messages, mode)
        if mode == "off" or not secrets_session then return messages end
        for _, m in ipairs(messages) do
            if type(m.content) == "string" then
                m.content = secrets_session:scrub(m.content, mode)
            end
            if m.tool_calls then
                for _, tc in ipairs(m.tool_calls) do
                    if tc["function"] and tc["function"].arguments then
                        tc["function"].arguments = secrets_session:scrub(
                            tc["function"].arguments, mode)
                    end
                end
            end
        end
        return messages
    end
    -- Rehydrate a tool-call args table (recursive). Used at MCP dispatch
    -- so the server sees the real values when the model emitted placeholders.
    local function rehydrate_args(t)
        if not secrets_session then return t end
        if type(t) == "string" then
            return secrets_session:rehydrate(t)
        elseif type(t) == "table" then
            for k, v in pairs(t) do t[k] = rehydrate_args(v) end
        end
        return t
    end

    -- Phase 5: render the evicted turns into a compact transcript for
    -- the summarizer prompt. Same shape as :memory summarize uses.
    local function render_evicted(turns)
        local parts = {}
        for _, t in ipairs(turns or {}) do
            parts[#parts + 1] = ("%s: %s"):format(
                t.role, (t.content or ""):gsub("\n", " "):sub(1, 600))
        end
        return table.concat(parts, "\n")
    end

    -- Phase 5: summarize_fn factory. Returns a closure that maps
    -- (prior_summary, evicted_turns) onto a broker.chat call against
    -- the configured summarizer model. Returns nil on any failure so
    -- Context falls back to silent eviction (Phase 0 behavior).
    local function make_summarize_fn()
        local sum_name = (config.context and config.context.summarizer_model)
                          or "fast"
        local sum_cfg  = config.models[sum_name]
        if not sum_cfg then return nil end
        return function(prior, evicted)
            local body
            if evicted == nil then
                body = "Compress this prior summary into 2-3 sentences. "
                    .. "Keep names, facts, decisions; drop chatter.\n\n"
                    .. "Prior summary:\n" .. (prior or "")
            elseif prior and prior ~= "" then
                body = "Extend this prior summary with the new turns. "
                    .. "Keep it 2-4 sentences. Preserve names, facts, decisions.\n\n"
                    .. "Prior summary:\n" .. prior
                    .. "\n\nNew turns:\n" .. render_evicted(evicted)
            else
                body = "Summarize the following conversation turns in "
                    .. "2-3 sentences. Preserve names, facts, decisions.\n\n"
                    .. render_evicted(evicted)
            end
            local sum_msgs = scrub_messages({
                { role = "system", content =
                    "Output exactly one short summary paragraph. "
                    .. "No commentary, no markdown, no bullet lists." },
                { role = "user", content = body },
            }, secrets_mode_for(sum_cfg))
            -- Phase 7: broker.chat returns (text, usage) on success or
            -- (nil, errmsg) on failure. Capture as (text, second); branch
            -- on text nil-ness to interpret second.
            local reply, second = broker.chat(sum_cfg, sum_msgs,
                { max_tokens = 300, timeout_ms = 30000,
                  category = "summarize" })
            if not reply then
                renderer.status("context summarize failed: " .. tostring(second))
                return nil
            end
            if second then  -- usage payload
                _record_usage(second.model, second.category, second)
            end
            if secrets_session then
                reply = secrets_session:rehydrate(reply)
            end
            return reply:gsub("^%s+", ""):gsub("%s+$", "")
        end
    end

    -- Build Context with optional summarize_fn (gated by cfg flag).
    local ctx_opts = {}
    if config.context then
        for k, v in pairs(config.context) do ctx_opts[k] = v end
    end
    -- #101: summarize_fn is also needed for cadence-triggered
    -- summarization (Context:enforce_cadence). Wire it whenever
    -- EITHER feature is enabled — same closure, different trigger.
    if config.context and (config.context.summarize_on_evict
                           or config.context.summarize_every_n_turns) then
        ctx_opts.summarize_fn = make_summarize_fn()
    end
    -- Phase 8 (docs/PHASE8.md): when cfg.tokenize.use_endpoint is true,
    -- wire a tokenize_fn so Context:estimate_tokens uses real counts
    -- from <endpoint>/tokenize (broker.token_count handles per-endpoint
    -- capability cache + char/4 fallback). R4: the closure body MUST
    -- reference `active_cfg` directly as an upvalue (NOT capture by
    -- value) so :model switches naturally re-route to the new model's
    -- tokenizer. A5 verified Lua upvalue semantics resolve at call time.
    if config.tokenize and config.tokenize.use_endpoint then
        ctx_opts.tokenize_fn = function(text)
            return broker.token_count(active_cfg, text)
        end
    end
    local ctx = Context.new(ctx_opts)

    -- Phase 2: MCP sessions. Populated from config.mcp.servers at startup
    -- (best-effort — failures are status-logged once, session absent from
    -- mcp_sessions until manual :mcp connect; no auto-retry per PHASE2.md
    -- §4 Lifecycle). Tools cached per-session for the session lifetime
    -- (lmcp announces capabilities.tools.listChanged = false).
    local mcp_sessions = {}  -- { [alias] = session }
    local function connect_mcp(alias, server_cfg)
        local sess = mcp.connect(server_cfg.url, {
            alias      = alias,
            auth_token = server_cfg.auth_token,
            auth_env   = server_cfg.auth_env,
        })
        local ok, kind, err = sess:initialize()
        if not ok then
            renderer.status(("mcp %s: %s (%s)")
                :format(alias, tostring(err), kind))
            return false
        end
        mcp_sessions[alias] = sess
        if sess.version_warning then
            renderer.status("mcp " .. alias .. ": " .. sess.version_warning)
        end
        -- Tool-name validation (issue #32): Anthropic via Bedrock enforces
        -- ^[a-zA-Z0-9_-]{1,128}$. We use "__" as the alias separator, so the
        -- emitted name is alias__tool. Warn at startup; emit anyway so local
        -- llama.cpp users aren't penalized for lenient downstreams.
        if alias:find("__", 1, true) then
            renderer.status(("mcp %s: alias contains '__' (used as separator); "
                .. "tool dispatch will misparse"):format(alias))
        end
        for _, t in ipairs(sess:list_tools()) do
            local full = alias .. "__" .. (t.name or "")
            if #full > 128 or full:find("[^%w_-]") then
                renderer.status(("mcp %s: tool name '%s' violates "
                    .. "^[a-zA-Z0-9_-]{1,128}$ (will fail with strict providers "
                    .. "e.g. anthropic via Bedrock)"):format(alias, full))
            end
        end
        return true, #sess:list_tools()
    end

    -- Walk config.mcp.auto_approve and warn about keys that match no live
    -- tool / no live alias (issue #33). Stale entries silently failed to
    -- auto-approve, leaving the user with unexpected confirm prompts.
    -- Called at startup AND after :mcp connect so newly-arrived sessions
    -- retroactively validate any keys that referenced them.
    local function validate_auto_approve()
        local policy = config.mcp and config.mcp.auto_approve
        if not policy then return end
        for key, _ in pairs(policy) do
            local alias_glob = key:match("^(.-)__%*$")
            if alias_glob then
                if not mcp_sessions[alias_glob] then
                    renderer.status(("auto_approve key '%s': no MCP server "
                        .. "connected for alias '%s'"):format(key, alias_glob))
                end
            else
                local alias, tname = key:match("^(.-)__(.+)$")
                if not alias or alias == "" or not tname then
                    renderer.status(("auto_approve key '%s': not in "
                        .. "'alias__tool' or 'alias__*' form"):format(key))
                else
                    local sess = mcp_sessions[alias]
                    if not sess then
                        renderer.status(("auto_approve key '%s': no MCP "
                            .. "server connected for alias '%s'")
                            :format(key, alias))
                    else
                        local found = false
                        for _, t in ipairs(sess:list_tools()) do
                            if t.name == tname then found = true; break end
                        end
                        if not found then
                            renderer.status(("auto_approve key '%s': "
                                .. "alias '%s' has no tool named '%s'")
                                :format(key, alias, tname))
                        end
                    end
                end
            end
        end
    end

    -- MCP ist seit 2026-08-07 OPT-IN. Es funktioniert unveraendert, kostet
    -- aber jede Anfrage ein Funktionsschema je Werkzeug je Server (~36 Blobs
    -- bei zwei lmcp-Hosts), und zwar bevor das Modell etwas gefragt wurde.
    -- Wer das will, setzt `mcp = { enabled = true, servers = {...} }`.
    if config.mcp and config.mcp.enabled and config.mcp.servers then
        for alias, server_cfg in pairs(config.mcp.servers) do
            local ok, n = connect_mcp(alias, server_cfg)
            if ok then
                renderer.status(("mcp %s: %d tools"):format(alias, n))
            end
        end
        validate_auto_approve()
    end

    -- sic: dieselbe Reichweite, drei Werkzeuge statt sechsunddreissig.
    -- Die Schemata der entfernten Werkzeuge holt das Modell bei Bedarf ueber
    -- sic__lmcp_list - fuer den einen Zug, in dem es sie braucht.
    -- Standardmaessig AN; `sic = { enabled = false }` schaltet es ab.
    if not (config.sic and config.sic.enabled == false) then
        local scfg = config.sic or {}
        local ssess = sicmod.connect({
            alias   = scfg.alias or "sic",
            hosts   = scfg.hosts or {},
            sic_bin = scfg.sic_bin,
        })
        local ok, kind, err = ssess:initialize()
        if ok then
            mcp_sessions[ssess.alias] = ssess
            renderer.status(("sic %s: %d tools (remote discovery on demand)")
                :format(ssess.alias, #ssess:list_tools()))
        else
            renderer.status(("sic: %s (%s)"):format(tostring(err), kind))
        end
    end

    -- Assemble OpenAI-shape `tools` array across all live sessions, with
    -- "alias__name" namespacing. Originally PHASE2 used "." as the separator,
    -- but Anthropic via Bedrock validates tool names against
    -- ^[a-zA-Z0-9_-]{1,128}$ and rejects dots — amended to "__" 2026-05-12.
    -- Empty array → broker omits the field entirely (§12 risk row 1).
    -- Aliases must not themselves contain "__" so the parse stays unambiguous.
    local function tools_schema()
        local out = {}
        for alias, sess in pairs(mcp_sessions) do
            for _, t in ipairs(sess:list_tools()) do
                out[#out + 1] = {
                    type = "function",
                    ["function"] = {
                        name        = alias .. "__" .. t.name,
                        description = t.description or "",
                        parameters  = t.inputSchema
                                        or { type = "object", properties = {} },
                    },
                }
            end
        end
        return out
    end

    -- §4 "Content flattening": tool results may carry multiple blocks; v1
    -- concatenates text and ignores non-text with a one-shot status.
    local non_text_warned = false
    local function flatten_content(content)
        local parts = {}
        local saw_non_text = false
        for _, b in ipairs(content or {}) do
            if b.type == "text" then
                parts[#parts + 1] = b.text or ""
            else
                saw_non_text = true
            end
        end
        if saw_non_text and not non_text_warned then
            non_text_warned = true
            renderer.status("tool returned non-text content blocks "
                            .. "(image/resource ignored in v1)")
        end
        return table.concat(parts, "\n")
    end

    -- Split <alias>__<tool>, look up session, call. Returns (content_string,
    -- is_error). Errors of all flavors (rpc, transport, missing alias)
    -- yield a synthesized "[aish] tool ... failed: ..." string so the
    -- caller always has a body for the role:"tool" turn — the strict-
    -- template alternation rationale per PHASE0.md §6 and the C5/C7 fold
    -- in PHASE2.md §4. Non-greedy "(.-)__(.+)" splits at the leftmost "__".
    local function dispatch_tool_call(name, args)
        local alias, tool_name = name:match("^(.-)__(.+)$")
        if not alias then
            return ("[aish] tool name has no alias prefix: %s"):format(name), true
        end
        local sess = mcp_sessions[alias]
        if not sess then
            return ("[aish] no MCP server connected for alias '%s'")
                    :format(alias), true
        end
        -- Issue #13: when secrets are configured, the model sees placeholders
        -- in its context and consequently emits placeholder-bearing tool args.
        -- The MCP server is treated as trusted local — rehydrate args before
        -- dispatch so the tool gets the real values.
        args = rehydrate_args(args)
        local result, kind, err = sess:call_tool(tool_name, args)
        if not result then
            if kind == "rpc_error" then
                local msg = (type(err) == "table" and err.message)
                              or tostring(err)
                return ("[aish] tool dispatch failed: %s"):format(msg), true
            else
                return ("[aish] tool transport error: %s")
                        :format(tostring(err)), true
            end
        end
        -- result has content[] and possibly isError=true. flatten_content
        -- handles the text-blocks-only flattening. We pass through the
        -- content body regardless of isError (per PHASE2-baseline.md §3:
        -- some tools set isError=false on actual failures, content text
        -- is authoritative).
        return flatten_content(result.content), (kind == "handler_error")
    end

    -- Session log (PHASE1.md §6). Always open one on startup; auto-write
    -- every user/assistant turn; close on :quit. If history.dir is set but
    -- unwritable, log a status and continue without persistence.
    local history_dir   = (config.history and config.history.dir) or nil
    local sessions_dir  = history_dir and (history_dir .. "/sessions") or nil
    local session_path  = sessions_dir
        and (sessions_dir .. "/" .. os.date("!%Y-%m-%dT%H-%M-%SZ") .. ".jsonl")
    local session
    if session_path then
        local sess, serr = history.open(session_path, {
            started      = os.date("!%Y-%m-%dT%H:%M:%SZ"),
            model        = active_name,
            aish_version = "phase1",
        })
        if sess then
            session = sess
        else
            renderer.status("session log disabled: " .. tostring(serr))
        end
    end
    -- Phase 4: memory.jsonl handle. Sibling of sessions/ in the history dir.
    -- Single-writer enforced via flock; if held by another aish process,
    -- status-log once and run without memory (Phase 3 behavior).
    local memory_path = history_dir and (history_dir .. "/memory.jsonl") or nil
    local memory                                 -- handle or nil
    local inject_max_chars =
        (config.memory and config.memory.inject_max_chars) or 2000

    -- Inject the top-N items into ctx.memory_items, capped by char budget.
    local function inject_memory()
        if not memory_path then ctx.memory_items = nil; return end
        local items = history.load_memory(memory_path)
        if #items == 0 then ctx.memory_items = nil; return end
        local picked, total = {}, 0
        for _, it in ipairs(items) do  -- already sorted by ts desc
            local cost = #(it.content or "") + 16  -- rough overhead per line
            if total + cost > inject_max_chars then break end
            picked[#picked + 1] = it
            total = total + cost
        end
        ctx.memory_items = picked
    end

    if memory_path then
        local m, merr = history.open_memory(memory_path)
        if m then
            memory = m
            inject_memory()
            if ctx.memory_items and #ctx.memory_items > 0 then
                renderer.status(("memory: %d items injected"):format(
                    #ctx.memory_items))
            end
        else
            renderer.status("memory disabled: " .. tostring(merr))
        end
    end

    local function log_turn(turn)
        if session then session:append(turn) end
    end

    -- Issue #10: configurable prompt template. When config.shell.prompt is
    -- set, substitute {model}/{ctx_used}/{ctx_max}/{turn}/{cwd}/{cwd_short}
    -- /{last_status}/{mode}. Otherwise fall back to the default with the
    -- norris ⚡ + plan markers.
    local libc = require("ffi.libc")
    local last_exec_code = nil
    local function _cwd_short()
        local c = libc.getcwd() or os.getenv("PWD") or "?"
        local home = os.getenv("HOME")
        if home and c:sub(1, #home) == home then
            c = "~" .. c:sub(#home + 1)
        end
        return c
    end
    local function _mode()
        if ctx.norris_active then return "norris" end
        if plan_mode             then return "plan"   end
        return "normal"
    end
    local function prompt()
        local tmpl = config.shell and config.shell.prompt
        if tmpl then
            local vars = {
                model       = active_name,
                ctx_used    = tostring(ctx:estimate_tokens()),
                ctx_max     = tostring(ctx.token_budget),
                turn        = tostring(#ctx.turns),
                cwd         = libc.getcwd() or "?",
                cwd_short   = _cwd_short(),
                last_status = last_exec_code and tostring(last_exec_code) or "",
                mode        = _mode(),
            }
            return (tmpl:gsub("{([%w_]+)}", function(k) return vars[k] or "" end))
        end
        if ctx.norris_active then
            return ("[aish:%s \xE2\x9A\xA1]> "):format(active_name)
        end
        if plan_mode then
            return ("[aish:%s plan]> "):format(active_name)
        end
        return ("[aish:%s]> "):format(active_name)
    end

    -- Phase 3: \C-n inserts ":norris " at the cursor so the user can type
    -- their goal and press Enter — routes through the meta dispatch
    -- normally. The :norris handler is implemented in `meta` below.
    rl.bind("\\C-n", function()
        rl.insert_text(":norris ")
        rl.redisplay()
    end)

    local function status_evictions(n)
        if n and n > 0 then
            renderer.status(("oldest %d turns evicted"):format(n))
        end
    end

    -- #101: status line for cadence-triggered fold.
    local function status_cadence_fold(n)
        if n and n > 0 then
            renderer.status(("proactively summarized %d older turns"):format(n))
        end
    end

    -- ── Phase 5: fallback eligibility per PHASE5.md §5 ──────────────────
    -- All transport-failure patterns must match against the err string
    -- as broker.lua emits it (with "transport: " prefix). The matcher
    -- strips the prefix before testing.
    local FALLBACK_PATTERNS = {
        "^HTTP 5%d%d",
        "^HTTP 404.*model_not_found",
        "^HTTP 408",
        "Couldn'?t resolve host",
        "Could not connect to server",  -- CURLE_COULDNT_CONNECT (port closed, host resolved)
        "Connection refused",
        "Timeout was reached",
        "Operation timed out",
    }
    local function fallback_reason(err)
        if type(err) ~= "string" then return "unknown error" end
        local stripped = err:gsub("^transport:%s*", "")
        for _, pat in ipairs(FALLBACK_PATTERNS) do
            if stripped:match(pat) then return (stripped:match(pat)) end
        end
        return nil
    end

    local function should_fallback(err)
        return config.routing and config.routing.fallback
               and fallback_reason(err) ~= nil
    end

    -- Phase 7 (R5): central chokepoint for usage recording. Wraps
    -- ctx:add_usage AND does the warn-threshold check. All callers
    -- (this file + safety.lua via helpers.on_usage / opts.on_usage)
    -- route through here so the warn check fires exactly once per
    -- accumulator update. Keeps context.lua decoupled from renderer.
    -- R2: caller passes the model name that should be CREDITED — for
    -- normal calls that's the active model; for fallback retries the
    -- broker's payload.model (which IS the fallback's model_cfg.model
    -- per broker emission) handles it correctly.
    _record_usage = function(model, category, usage)
        if not usage then return end
        ctx:add_usage(model, category, usage)
        if not (config.cost) then return end
        local cw = ctx.cost_warn_state
        if config.cost.warn_at_dollars and not cw.dollars then
            local cost = ctx:total_cost()
            if cost >= config.cost.warn_at_dollars then
                renderer.status(("session cost $%.6f has crossed warn_at_dollars=$%.6f")
                                :format(cost, config.cost.warn_at_dollars))
                cw.dollars = true
            end
        end
        if config.cost.warn_at_tokens and not cw.tokens then
            local p, c = ctx:total_tokens()
            if (p + c) >= config.cost.warn_at_tokens then
                renderer.status(("session tokens %d has crossed warn_at_tokens=%d")
                                :format(p + c, config.cost.warn_at_tokens))
                cw.tokens = true
            end
        end
    end

    -- Wrap broker.chat_stream with the Phase 5 fallback-retry path.
    -- Retries ONCE against cfg.routing.fallback_model (default "cloud")
    -- when (a) cfg.routing.fallback is true, (b) err matches a
    -- fallback-eligible pattern, AND (c) no deltas have arrived yet
    -- (mid-stream failures aren't retried — partial text would be
    -- duplicated).
    --
    -- Phase 7 (R2): wrapped on_delta keys usage by payload.model
    -- (set inside broker.lua from model_cfg.model — the
    -- CALLER-INTENDED model name). When fallback fires, the broker
    -- is called with fb_cfg, so payload.model is naturally the
    -- fallback's model name — wrapper doesn't need to track
    -- primary-vs-fallback itself.
    local function call_broker(model_cfg, model_name, msgs, on_delta, opts)
        local any_delta = false
        local wrapped = function(kind, payload)
            if kind == "usage" then
                _record_usage(payload.model, payload.category, payload)
                return  -- usage isn't forwarded to the underlying on_delta
            end
            any_delta = true
            return on_delta(kind, payload)
        end
        local ok, err = broker.chat_stream(model_cfg, msgs, wrapped, opts)
        if ok then return ok end
        if any_delta then return ok, err end          -- mid-stream — don't retry
        if not should_fallback(err) then return ok, err end
        local fb_name = (config.routing and config.routing.fallback_model)
                          or "cloud"
        local fb_cfg = config.models[fb_name]
        if not fb_cfg then return ok, err end
        renderer.status(("local %s failed (%s); retrying via %s")
                        :format(model_name, fallback_reason(err), fb_name))
        return broker.chat_stream(fb_cfg, msgs, wrapped, opts)
    end

    -- Run a shell command, framing output and (per config.shell.capture_output)
    -- buffering it for the NEXT user turn — context.append_exec_output keeps
    -- a [exec output] block pending until ask_ai flushes it via append_user.
    -- Direct user-role injection violated chat-template alternation (mistral-
    -- nemo's Jinja rejects user/user back-to-back); see PHASE0.md §6.
    --
    -- Issue #3: pre_cmd / post_cmd hooks fire around exec. Each hook
    -- receives the command on stdin and AISH_CMD/AISH_TURN/AISH_CWD as
    -- env vars. Non-zero exit on pre_cmd aborts. post_cmd exit is
    -- ignored; its stdout is logged via renderer.status.
    -- _shq lifted to module scope (above expand_mentions) so the
    -- @-mention diff retry can share the same quoter.
    local function _run_hook(script, cmd, want_output)
        local cwd = (require("ffi.libc").getcwd()) or os.getenv("PWD") or "?"
        local pipeline = string.format(
            "printf '%%s' %s | AISH_CMD=%s AISH_TURN=%d AISH_CWD=%s %s 2>&1",
            _shq(cmd), _shq(cmd), #ctx.turns, _shq(cwd), _shq(script))
        if want_output then
            local out, code = executor.exec(pipeline)
            return code, out
        else
            local out, code = executor.exec(pipeline)
            -- Even when we don't *want* output, surface it if the hook
            -- aborts so the user sees why.
            return code, out
        end
    end

    -- _git_clean_cmd lifted to module scope (above expand_mentions);
    -- shared with the @<r1>..<r2> @-mention diff retry. Same B1
    -- invariant: every git invocation that flows back into context
    -- runs with `--no-pager -c color.ui=never`.

    -- Phase 6 highlighter (commit #5): tree-sitter CLI detection +
    -- per-language extension map + path-based dispatch.
    --
    -- R4 resolution: the upstream `tree-sitter highlight` CLI takes a
    -- PATH (no --lang flag); language is inferred from the file
    -- extension. Empty `--scope source.X` is also unreliable
    -- without configured grammars. So we name the tmpfile with the
    -- canonical extension for `lang` and let the CLI dispatch.
    --
    -- Additional B4-followup: even with the CLI installed, highlighting
    -- requires parser-directories configured AND grammars cloned + built.
    -- Without those, every highlight call emits a warning to stderr and
    -- returns empty stdout. We treat empty/error as pass-through (body
    -- returned as-is).
    local LANG_EXTENSION = {
        lua = ".lua", python = ".py", javascript = ".js", typescript = ".ts",
        bash = ".sh", c = ".c", cpp = ".cpp", rust = ".rs", go = ".go",
        java = ".java", ruby = ".rb", markdown = ".md", json = ".json",
        yaml = ".yaml", toml = ".toml", html = ".html", css = ".css",
        sql = ".sql", xml = ".xml",
    }
    -- Map lang-tag (as it appears in ```<tag>) to canonical lang. Mirrors
    -- expand_mentions LANG_BY_EXT but indexed by tag (e.g., "py" -> "python").
    local LANG_TAG = {
        py = "python", python = "python",
        lua = "lua",
        js = "javascript", javascript = "javascript",
        ts = "typescript", typescript = "typescript",
        sh = "bash", bash = "bash",
        c = "c", cpp = "cpp", cc = "cpp",
        rs = "rust", go = "go", java = "java", rb = "ruby", ruby = "ruby",
        md = "markdown", markdown = "markdown",
        json = "json", yaml = "yaml", yml = "yaml", toml = "toml",
        html = "html", css = "css", sql = "sql", xml = "xml",
    }
    local function _detect_treesitter()
        local pipe = io.popen("command -v tree-sitter 2>/dev/null && tree-sitter --version 2>/dev/null")
        -- N2 / B3: pipe:close() returns true on LuaJIT regardless of exit
        -- code; we don't use it for the verdict. Presence of an output
        -- line from --version is the actual signal.
        local ok = pipe and pipe:read("*l") and pipe:close()
        return ok and true or false
    end
    local highlight_enabled  = false
    local highlight_detected = _detect_treesitter()

    -- highlighted(body, lang_tag) — R2-placed in repl.lua so it has
    -- access to _shq + executor. Returns the rendered body (with ANSI)
    -- or `body` unchanged on any failure (silent pass-through so the
    -- user never sees a broken highlighter swallow their code block).
    local function highlighted(body, lang_tag)
        if not highlight_enabled then return body end
        local lang = LANG_TAG[(lang_tag or ""):lower()]
        local ext  = lang and LANG_EXTENSION[lang]
        if not ext then return body end
        -- B3: io.popen close doesn't expose exit code; route via
        -- executor.exec (pty.spawn + waitpid) for reliable (out, code).
        local tmp = os.tmpname() .. ext
        local f = io.open(tmp, "wb")
        if not f then return body end
        f:write(body); f:close()
        local out, code = executor.exec(
            ("tree-sitter highlight %s 2>/dev/null"):format(_shq(tmp)))
        os.remove(tmp)
        if code ~= 0 or not out or out == "" then return body end
        return out
    end

    -- Wire the filter into renderer (off by default; user opts in via
    -- :highlight on). Even when off, we set the callback so a later
    -- toggle works without reinitialization.
    renderer.set_highlight(highlight_enabled, highlight_detected, highlighted)

    -- Phase 6 (§6 + N4): project file-tree scanner. Prefers
    -- `git -C <dir> ls-files --cached --others --exclude-standard`
    -- when <dir> is inside a git repo (free .gitignore honor);
    -- falls back to `find ... -not -path '*/.<wildcard>'` for non-repo
    -- cwds. opts: { depth = N, max_chars = N }; defaults via cfg.project.
    -- Returns (body, info) where info = { file_count, truncated }.
    local function _scan_project_tree(dir, opts)
        opts = opts or {}
        local p_cfg     = config.project or {}
        local depth     = opts.depth     or p_cfg.tree_depth     or 3
        local max_chars = opts.max_chars or p_cfg.tree_max_chars or 4096

        -- N4: `git -C <dir>` skips the subshell vs `cd && git ...`.
        local in_git = os.execute(
            ("git -C %s rev-parse --git-dir >/dev/null 2>&1"):format(_shq(dir))
        ) == 0
        local listcmd
        if in_git then
            listcmd = ("git -C %s ls-files --cached --others --exclude-standard")
                        :format(_shq(dir))
        else
            -- find honors -maxdepth from the start path; we count the
            -- depth in terms of nested subdirectories beneath <dir>.
            listcmd = ("find %s -mindepth 1 -maxdepth %d -type f -not -path '*/.*' 2>/dev/null")
                        :format(_shq(dir), depth + 1)
        end

        local pipe = io.popen(listcmd)
        if not pipe then return nil, "scan failed (popen)" end

        local files = {}
        for line in pipe:lines() do
            -- Depth filter is a no-op for the git case (ls-files emits
            -- full repo-relative paths); for find we already capped via
            -- -maxdepth. Keep the slash count here as a defensive bound.
            local _, slashes = line:gsub("/", "")
            if slashes <= depth then files[#files + 1] = line end
        end
        pipe:close()

        table.sort(files)

        local body = table.concat(files, "\n")
        local truncated = false
        if #body > max_chars then
            body = body:sub(1, max_chars) .. "\n... (truncated)"
            truncated = true
        end
        return body, { file_count = #files, truncated = truncated, in_git = in_git }
    end

    local function run_shell(cmd)
        local chd, err = executor.maybe_chdir(cmd)
        if chd ~= nil then
            if chd then
                local pwd = io.popen("pwd"):read("*l") or "?"
                renderer.status("cwd -> " .. pwd)
            else
                renderer.status("cd: " .. tostring(err))
            end
            return
        end
        local hooks = config.hooks or {}
        if hooks.pre_cmd then
            local rc = _run_hook(hooks.pre_cmd, cmd, false)
            if rc ~= 0 then
                renderer.status(("pre_cmd hook aborted (exit %d): %s")
                                :format(rc, cmd))
                last_exec_code = rc
                return
            end
        end
        renderer.exec_begin()
        local out, code = executor.exec(cmd)
        last_exec_code = code
        renderer.exec_end(code)
        if config.shell and config.shell.capture_output then
            ctx:append_exec_output(out)
        end
        if hooks.post_cmd then
            _run_hook(hooks.post_cmd, cmd, true)
        end
    end

    -- Send user text to the active model and process the response. If MCP
    -- tools are connected and the model emits tool_calls, dispatch each
    -- call (with safety confirm gate), append role:"tool" turns, and
    -- re-call the broker — looping until the model returns pure text or
    -- max_tool_depth is hit. CMD: extraction runs ONCE on the final
    -- pure-text response (the §6 substrate invariant is unchanged).
    local max_tool_depth = (config.mcp and config.mcp.max_tool_depth) or 8

    local function ask_ai(text)
        local prev_pending = ctx.pending_exec_output
        ctx:append_user(text)
        log_turn(ctx.turns[#ctx.turns])

        -- Phase 5 R-C2: routing decision taken ONCE on entry to ask_ai.
        -- req_name/req_cfg are used for every iteration of the
        -- tool-sub-loop; active_name/active_cfg are NOT mutated so the
        -- user's :model selection survives the request.
        local req_name, req_cfg = active_name, active_cfg
        local req_class
        if config.routing and config.routing.auto then
            local routed, class = router.classify_model(text, config)
            req_class = class
            if routed and config.models[routed] and routed ~= active_name then
                renderer.status(("routed to %s (%s class)"):format(routed, class))
                req_name, req_cfg = routed, config.models[routed]
            end
        end
        -- Phase 10 (#86): per-class system_prompt override. Tightens
        -- instruction adherence on small local models. Lookup is
        -- gated on auto-routing being on (no class -> no override).
        -- When the override is set, ctx:to_messages REPLACES the base
        -- system_prompt for THIS render only; dynamic blocks
        -- ([background], [project], [earlier summary], NORRIS suffix)
        -- still compose on top. Norris path is unaffected — it builds
        -- its own messages array inside safety.norris_step.
        local sys_override = config.routing
                         and config.routing.system_prompts
                         and req_class
                         and config.routing.system_prompts[req_class]
        -- #88: per-class GBNF grammar passthrough. llama.cpp constrains
        -- the sampler to only emit tokens matching the grammar — kills
        -- format drift on small models. Cloud silently ignores the
        -- field (probed Anthropic/Bedrock returns normally).
        local grammar_override = config.routing
                             and config.routing.grammars
                             and req_class
                             and config.routing.grammars[req_class]
        -- #87: route-aware context compression. When the routed model
        -- preset has `local_compress = true`, ctx:to_messages keeps only
        -- the last N turns and tail-truncates oversized content for
        -- THIS request. Cloud routes (model_cfg.local_compress nil/false)
        -- get the full context unchanged. Defaults from cfg.context.compress;
        -- per-model opt-in keeps the design surface predictable.
        local compress_opts
        if req_cfg and req_cfg.local_compress then
            local cc = (config.context and config.context.compress) or {}
            compress_opts = {
                keep_turns     = cc.keep_turns     or 2,
                max_turn_chars = cc.max_turn_chars or 800,
            }
        end

        local depth = 0
        local final_resp = ""
        local first_iteration = true

        while true do
            local text_parts      = {}
            local tool_calls_seen = {}
            local redact_mode     = secrets_mode_for(req_cfg)
            local scrubbed_msgs   = scrub_messages(
                ctx:to_messages({
                    system_prompt_override = sys_override,
                    compress               = compress_opts,
                }),
                redact_mode)
            -- Streaming rehydrator wraps the on_delta so the user sees real
            -- values; text_parts accumulates the REHYDRATED chunks so
            -- final_resp (used for CMD: / DELEGATE: extraction) is plain.
            local rehydrator = secrets_session
                and secrets.streaming_rehydrator(secrets_session)
                or  nil
            local ok, err = call_broker(req_cfg, req_name, scrubbed_msgs,
                function(kind, payload)
                    if kind == "text" then
                        local emit = rehydrator and rehydrator:push(payload)
                                                  or payload
                        if emit ~= "" then
                            text_parts[#text_parts + 1] = emit
                            renderer.assistant_delta(emit)
                        end
                    elseif kind == "tool_call" then
                        tool_calls_seen[#tool_calls_seen + 1] = payload
                    end
                end,
                { tools = tools_schema(), category = "main",
                  grammar = grammar_override })
            if rehydrator then
                local tail = rehydrator:flush()
                if tail ~= "" then
                    text_parts[#text_parts + 1] = tail
                    renderer.assistant_delta(tail)
                end
            end
            renderer.assistant_flush()

            if not ok then
                renderer.status("broker error: " .. tostring(err))
                if first_iteration then
                    -- Back out the user turn so :resume / retry is clean.
                    table.remove(ctx.turns)
                    ctx.pending_exec_output = prev_pending
                end
                return
            end
            first_iteration = false

            local resp_text = table.concat(text_parts)

            if #tool_calls_seen == 0 then
                -- Pure text response — end of this AI turn.
                ctx:append({ role = "assistant", content = resp_text })
                log_turn(ctx.turns[#ctx.turns])
                final_resp = resp_text
                break
            end

            -- Record the assistant turn with text AND tool_calls. Content
            -- may be "" (C3: model often emits no prose before a call).
            ctx:append({
                role       = "assistant",
                content    = resp_text,
                tool_calls = tool_calls_seen,
            })
            log_turn(ctx.turns[#ctx.turns])

            -- Process each tool_call. Every iteration appends EXACTLY one
            -- role:"tool" turn per call (keeps alternation legal even on
            -- decline/error per C5/C7).
            for _, call in ipairs(tool_calls_seen) do
                local args_table, args_err
                if call.arguments and call.arguments ~= "" then
                    args_table, _, args_err = json.decode(call.arguments)
                else
                    args_table = {}
                end

                local tool_content, is_error
                if args_err then
                    tool_content = ("[aish] tool arguments not parseable as "
                                    .. "JSON: %s"):format(tostring(args_err))
                    is_error = true
                    renderer.tool_call_begin(call.name, call.arguments)
                    renderer.tool_call_end(tool_content, true)
                elseif not safety.confirm_tool_call(call.name, args_table,
                                                   config) then
                    tool_content = "[aish] tool call declined by user"
                    is_error = true
                    renderer.status(tool_content)
                else
                    renderer.tool_call_begin(call.name, call.arguments)
                    local content, errflag = dispatch_tool_call(call.name,
                                                                args_table)
                    tool_content = content
                    is_error = errflag
                    renderer.tool_call_end(content, errflag)
                end

                ctx:append({
                    role         = "tool",
                    tool_call_id = call.id,
                    content      = tool_content,
                })
                log_turn(ctx.turns[#ctx.turns])
            end

            depth = depth + 1
            if depth >= max_tool_depth then
                renderer.status(("tool-call depth limit reached (%d); "
                                 .. "stopping sub-loop"):format(max_tool_depth))
                final_resp = resp_text
                break
            end
            -- loop body re-runs broker.chat_stream with the now-extended ctx
        end

        -- #101: proactively fold older turns into ctx.summary on
        -- cadence (when cfg.context.summarize_every_n_turns is set).
        -- BEFORE enforce_budget so it sees a tighter token estimate
        -- and doesn't evict turns we just summarized.
        status_cadence_fold(ctx:enforce_cadence())
        status_evictions(ctx:enforce_budget())

        -- CMD: extraction on the final pure-text response only.
        for _, cmd in ipairs(executor.extract_cmd_lines(final_resp)) do
            if plan_mode then
                -- Issue #5: print PLAN: and feed back as a would-have-run
                -- note. Same context flow as a real exec output so the
                -- model can iterate on the plan turn by turn.
                renderer.status(("PLAN: %s"):format(cmd))
                ctx:append_exec_output(("[plan] would run: %s"):format(cmd))
            else
                -- Issue #9: permission policy DSL — verdict drives the gate.
                -- Falls back to shell.confirm_cmd boolean when config.permissions
                -- is unset (backward compat).
                local verdict, rule = safety.classify_command(cmd, config)
                local doit = false
                if verdict == "allow" then
                    doit = true
                elseif verdict == "deny" then
                    renderer.status(("denied by policy [%s]: %s")
                                    :format(rule or "default", cmd))
                else  -- "confirm"
                    local ans = rl.readline(("execute '%s'? [y/N] "):format(cmd)) or ""
                    doit = (ans:lower():sub(1, 1) == "y")
                end
                if doit then run_shell(cmd) end
            end
        end

        -- Issue #8: CMD&: extraction — spawn each as a background job.
        -- No confirm gate in v1 (the model issuing CMD&: is opting into the
        -- async path; permission policy is still bypassed there. Revisit
        -- once #9 is generalized beyond the synchronous CMD: gate).
        for _, cmd in ipairs(executor.extract_cmd_bg_lines(final_resp)) do
            if plan_mode then
                renderer.status(("PLAN: & %s"):format(cmd))
                ctx:append_exec_output(("[plan] would bg-run: %s"):format(cmd))
            else
                local job, err = _bg_spawn(cmd)
                if not job then
                    renderer.status(("bg spawn failed: %s"):format(tostring(err)))
                    ctx:append_exec_output(("[bg failed to start]: %s"):format(cmd))
                else
                    local note = ("[bg:%d started pid=%d]: %s")
                                    :format(job.id, job.pid, cmd)
                    renderer.status(note)
                    ctx:append_exec_output(note)
                end
            end
        end

        -- Issue #6: DELEGATE: <preset> "<prompt>" — sub-broker call against
        -- a different model preset. Result is fed back as exec-output so the
        -- model sees it on the next turn. Synchronous (blocks the current
        -- ask_ai return until each delegate resolves). Cost note: a DELEGATE
        -- to a paid cloud preset spends API tokens silently — the user has
        -- already opted in by configuring the preset.
        for _, d in ipairs(executor.extract_delegate_lines(final_resp)) do
            local sub_cfg = config.models[d.preset]
            if plan_mode then
                renderer.status(("PLAN: DELEGATE %s \"%s\""):format(d.preset, d.prompt))
                ctx:append_exec_output(
                    ("[plan] would delegate to %s: %s"):format(d.preset, d.prompt))
            elseif not sub_cfg then
                renderer.status(("DELEGATE: unknown preset '%s'"):format(d.preset))
                ctx:append_exec_output(
                    ("[delegate %s failed: unknown preset]"):format(d.preset))
            else
                renderer.status(("DELEGATE -> %s: %s"):format(d.preset, d.prompt))
                local sub_msgs = scrub_messages(
                    { { role = "user", content = d.prompt } },
                    secrets_mode_for(sub_cfg))
                -- Phase 7: capture (text, usage) — second is err on failure.
                local sub_text, second = broker.chat(sub_cfg, sub_msgs,
                    { category = "delegate" })
                if not sub_text then
                    renderer.status(("delegate %s failed: %s"):format(d.preset, tostring(second)))
                    ctx:append_exec_output(
                        ("[delegate %s failed: %s]"):format(d.preset, tostring(second)))
                else
                    if second then  -- usage payload
                        _record_usage(second.model, second.category, second)
                    end
                    -- Rehydrate the reply so the model sees its own
                    -- secrets restored when this gets re-serialized
                    -- on the next ask_ai turn.
                    if secrets_session then
                        sub_text = secrets_session:rehydrate(sub_text)
                    end
                    ctx:append_exec_output(
                        ("[delegate %s]: %s"):format(d.preset, sub_text))
                end
            end
        end
    end

    -- #102: extracted distill body from `:memory summarize` so
    -- shutdown_session can fire it non-interactively on :q when
    -- cfg.memory.auto_summarize_on_quit is set. opts.auto = true
    -- skips per-candidate readline prompts and auto-adds every
    -- parsed candidate; opts.min_turns is the cutoff below which
    -- auto mode is a no-op.
    --
    -- Returns (added_count, total_candidates) or (nil, err_msg).
    -- Status messages are only emitted in interactive (non-auto)
    -- mode so :q doesn't print noise on a no-op.
    local function _do_memory_summarize(opts)
        opts = opts or {}
        local auto = opts.auto
        if not memory then
            if not auto then renderer.status("memory unavailable") end
            return nil, "memory unavailable"
        end
        if not session_path then
            if not auto then renderer.status("no session log to summarize") end
            return nil, "no session log"
        end

        local turns, _meta = history.load(session_path)
        if not turns or #turns == 0 then
            if not auto then renderer.status("session log empty; nothing to summarize") end
            return nil, "session empty"
        end

        -- Auto mode: skip below-threshold sessions silently.
        if auto and opts.min_turns and #turns < opts.min_turns then
            return nil, "below threshold"
        end

        local filtered = {}
        for _, t in ipairs(turns) do
            if t.meta ~= "summarize" then
                filtered[#filtered + 1] =
                    ("%s: %s"):format(t.role,
                                      (t.content or ""):gsub("\n", " "):sub(1, 800))
            end
        end
        local transcript = table.concat(filtered, "\n")
        if #transcript < 50 then
            if not auto then renderer.status("session content too short to summarize") end
            return nil, "too short"
        end

        -- Pick summarizer model. #102: cfg.memory.summary_model is the
        -- per-#102 alias; fall back to the existing summarizer_model
        -- key (Phase 5 / Phase 4 parity), then active model name.
        local sum_name = (config.memory and (config.memory.summary_model
                                             or config.memory.summarizer_model))
                         or active_name
        local sum_cfg = config.models[sum_name]
        if not sum_cfg then
            if not auto then renderer.status("summarizer model not found: " .. sum_name) end
            return nil, "summarizer model not found: " .. sum_name
        end

        if not auto then
            renderer.status(("summarizing via %s ..."):format(sum_name))
        else
            renderer.status(("summarizing session for memory via %s ...")
                            :format(sum_name))
        end
        local sum_msgs = scrub_messages({
            { role = "system", content =
                "Read the following conversation transcript. Extract "
                .. "facts, preferences, or context worth remembering "
                .. "across future sessions. Output ONE candidate per "
                .. "line, prefixed with the kind: \"fact: ...\", "
                .. "\"pref: ...\", or \"context: ...\". Maximum 10 "
                .. "candidates. No commentary outside candidate lines."
            },
            { role = "user", content = transcript },
        }, secrets_mode_for(sum_cfg))
        local reply, second = broker.chat(sum_cfg, sum_msgs,
            { max_tokens = 1024, timeout_ms = 90000,
              category = "memory_summarize" })

        if not reply then
            if not auto then
                renderer.status("summarize failed: " .. tostring(second))
            end
            return nil, "broker failed: " .. tostring(second)
        end
        if second then  -- usage payload
            _record_usage(second.model, second.category, second)
        end
        if secrets_session then
            reply = secrets_session:rehydrate(reply)
        end

        log_turn({ role = "assistant", content = reply, meta = "summarize" })

        local candidates = {}
        for line in (reply .. "\n"):gmatch("([^\n]*)\n") do
            local kind, body = line:match("^%s*[-*]?%s*[*_]*(%a+)[*_]*%s*:%s*(.+)$")
            if kind then
                kind = kind:lower()
                if kind == "fact" or kind == "pref" or kind == "context" then
                    candidates[#candidates + 1] = { kind = kind,
                                                    content = body:gsub("%s+$", "") }
                end
            end
        end

        if #candidates == 0 then
            if not auto then renderer.status("no candidates parsed from response") end
            return 0, 0
        end

        local added = 0
        if auto then
            -- Auto mode: trust the model + opt-in (cfg.memory.auto_summarize_on_quit).
            -- Add every parsed candidate; no interactive prompt.
            for _, cand in ipairs(candidates) do
                memory:add(cand.kind, cand.content)
                added = added + 1
            end
            inject_memory()
            renderer.status(("auto-summarize: added %d memory item%s")
                             :format(added, added == 1 and "" or "s"))
        else
            for _, cand in ipairs(candidates) do
                io.write(("\n[memory] candidate (%s): %s\n")
                         :format(cand.kind, cand.content))
                local ans = rl.readline("keep? [y/N/edit] ") or ""
                local first = ans:lower():sub(1, 1)
                if first == "y" then
                    memory:add(cand.kind, cand.content)
                    added = added + 1
                elseif first == "e" then
                    local edited = rl.readline("edit: ") or ""
                    edited = edited:gsub("^%s+", ""):gsub("%s+$", "")
                    if edited ~= "" then
                        memory:add(cand.kind, edited)
                        added = added + 1
                    end
                end
            end
            inject_memory()
            renderer.status(("summarize: added %d / %d candidates")
                             :format(added, #candidates))
        end
        return added, #candidates
    end

    local function shutdown_session()
        -- #102: auto-summarize on :q. Gate on cfg.memory.auto_summarize_on_quit
        -- AND a turn count threshold (default 5) so trivial sessions don't
        -- pollute memory.jsonl. pcall so a broker failure never blocks
        -- shutdown.
        if config.memory and config.memory.auto_summarize_on_quit then
            local min = config.memory.min_turns_for_summary or 5
            pcall(_do_memory_summarize, { auto = true, min_turns = min })
        end
        if session then session:close(); session = nil end
        if memory  then memory:close();  memory  = nil end
    end

    -- ---------------------------------------------------------------- Norris driver
    -- The Phase 3 autonomous mode driver. Sets ctx.norris_active +
    -- ctx.norris_goal so context.to_messages() composes the NORRIS MODE
    -- system-prompt suffix on each broker call. Loops calling
    -- safety.norris_step until the planner returns a terminal status.
    local max_norris_steps =
        (config.safety and config.safety.max_norris_steps) or 8

    -- The HALT prompt — proceed / skip / abort. Returns one of those
    -- three verdict strings. Used by safety.norris_step via the helpers
    -- table. \C-x\C-c also aborts (PHASE1.md §7 reserved key).
    local function norris_halt(step_n, max_n, reason, action)
        renderer.norris_halt(step_n, max_n, reason, action)
        local ans = rl.readline("[N] proceed / skip / abort? ") or ""
        local first = ans:lower():sub(1, 1)
        if first == "p" then return "proceed" end
        if first == "s" then return "skip"    end
        return "abort"  -- empty input or anything else → abort (safe default)
    end

    -- Dispatch an MCP tool by name. Returns (content_string, is_error).
    -- Mirrors what the Phase 2 ask_ai tool path does, but factored so
    -- safety.norris_step can call it via helpers.
    local function dispatch_tool(name, args)
        local alias, tool_name = name:match("^(.-)__(.+)$")
        if not alias or alias == "" then
            return ("[aish] tool name has no alias prefix: %s"):format(name), true
        end
        local sess = mcp_sessions[alias]
        if not sess then
            return ("[aish] no MCP server connected for alias '%s'")
                    :format(alias), true
        end
        local result, kind, err = sess:call_tool(tool_name, args)
        if not result then
            if kind == "rpc_error" then
                local msg = (type(err) == "table" and err.message) or tostring(err)
                return ("[aish] tool dispatch failed: %s"):format(msg), true
            else
                return ("[aish] tool transport error: %s"):format(tostring(err)), true
            end
        end
        local parts = {}
        for _, b in ipairs(result.content or {}) do
            if b.type == "text" then parts[#parts + 1] = b.text or "" end
        end
        return table.concat(parts, "\n"), (kind == "handler_error")
    end

    -- Exec a shell command for Norris (mirrors run_shell minus the cd
    -- intercept which is interactive-only). Returns (output, exit_code).
    local function norris_exec(cmd)
        local chd, _ = executor.maybe_chdir(cmd)
        if chd ~= nil then
            -- cd in autonomous mode just changes our cwd silently
            return chd and "" or "[aish] cd failed", 0
        end
        return executor.exec(cmd)
    end

    local function run_norris(goal)
        -- Phase 10 R4: clear all Norris state at the TOP. If a prior
        -- Norris session crashed (uncaught broker error) leaving
        -- norris_active/_goal/_tasks stale, a fresh launch should
        -- start clean. Cheaper than wrapping the driver in pcall.
        ctx.norris_active            = true
        ctx.norris_goal              = goal
        ctx.norris_consecutive_skips = 0
        ctx.norris_tasks             = nil
        ctx:append_user(("[norris] %s"):format(goal))
        log_turn(ctx.turns[#ctx.turns])

        renderer.norris_begin(goal)

        -- Phase 10 / #89: cloud preplanner emits a TASK list ONCE per
        -- :norris launch when cfg.norris.preplanner names a model
        -- preset. Cheap fall-back paths everywhere: any failure keeps
        -- single-model Norris behavior intact.
        if config.norris and config.norris.preplanner then
            local pre_name = config.norris.preplanner
            local pre_cfg  = config.models and config.models[pre_name]
            if not pre_cfg then
                renderer.status(("preplanner '%s' is not in cfg.models; "
                    .. "running single-model"):format(pre_name))
            else
                local sys = (config.norris.preplan_system) or [[
You are a task decomposer. Given the user's goal, decompose it into a
sequence of single-step imperative TASKs. Output format: one TASK per
line, EXACTLY this shape:

  TASK: <imperative sentence, max 80 chars>

Output AT MOST %d tasks. No prose; no numbering; no commentary outside
the TASK: lines.
]]
                -- R1: use %d/format; gsub("N", ...) would corrupt "No prose".
                local tasks_max = config.norris.tasks_max or 16
                sys = string.format(sys, tasks_max)

                local msgs = scrub_messages({
                    { role = "system", content = sys },
                    { role = "user",   content = goal },
                }, secrets_mode_for(pre_cfg))

                local text, usage = broker.chat(pre_cfg, msgs, {
                    category   = "norris-preplan",
                    max_tokens = 800,
                    -- R7: respect the configured per-model timeout.
                    timeout_ms = pre_cfg.timeout_ms or 60000,
                })

                if not text then
                    renderer.status(("preplan failed: %s; "
                        .. "running single-model"):format(tostring(usage)))
                else
                    if secrets_session then
                        text = secrets_session:rehydrate(text)
                    end
                    if usage then
                        _record_usage(usage.model, usage.category, usage)
                    end
                    local parsed = executor.extract_task_lines(text)
                    if #parsed > tasks_max then
                        for i = #parsed, tasks_max + 1, -1 do parsed[i] = nil end
                        renderer.status(("preplan emitted >%d tasks; "
                            .. "truncated"):format(tasks_max))
                    end
                    if #parsed == 0 then
                        renderer.status("preplan produced no TASK lines; "
                            .. "running single-model")
                    else
                        ctx.norris_tasks = { current = 1, list = parsed }
                        renderer.status(("preplanned %d tasks via %s")
                            :format(#parsed, pre_name))
                    end
                end
            end
        end

        -- Phase 10: resolve the EXECUTOR cfg independently of preplan
        -- (Q-PP1: cfg.norris.executor applies even without preplanner).
        -- Fall-back: unset OR not in cfg.models -> active_cfg (the user's
        -- current :model selection — existing Phase 3 behavior).
        local executor_cfg = active_cfg
        if config.norris and config.norris.executor then
            local exe_name = config.norris.executor
            local exe_cfg  = config.models and config.models[exe_name]
            if exe_cfg then
                executor_cfg = exe_cfg
            else
                renderer.status(("executor '%s' is not in cfg.models; "
                    .. "using active model"):format(exe_name))
            end
        end

        local helpers = {
            tools_schema             = tools_schema,
            exec_cmd                 = norris_exec,
            dispatch_tool            = dispatch_tool,
            extract_cmd_lines        = executor.extract_cmd_lines,
            halt                     = norris_halt,
            render_step              = renderer.norris_step,
            render_tool_begin        = renderer.tool_call_begin,
            render_tool_end          = renderer.tool_call_end,
            render_exec_begin        = renderer.exec_begin,
            render_exec_end          = renderer.exec_end,
            render_assistant_delta   = renderer.assistant_delta,
            render_assistant_flush   = renderer.assistant_flush,
            log_turn                 = log_turn,
            -- Issue #52: pass secrets-aware callbacks so safety.lua
            -- can scrub outbound Norris broker messages + LLM probe
            -- inputs + rehydrate streamed replies. All three are nil-
            -- safe; safety.lua only wires them in when present.
            scrub_msgs = function(msgs, mode_cfg)
                return scrub_messages(msgs, secrets_mode_for(mode_cfg or active_cfg))
            end,
            rehydrate = function(text)
                return secrets_session and secrets_session:rehydrate(text) or text
            end,
            streaming_rehydrator = function()
                return secrets_session
                    and secrets.streaming_rehydrator(secrets_session)
                    or  nil
            end,
            -- Phase 7: hand the central usage chokepoint to safety.lua.
            -- safety.norris_step routes the Norris main broker's usage
            -- here under category="norris"; safety.is_destructive's LLM
            -- probe routes via opts.on_usage under category="probe".
            on_usage = _record_usage,
        }

        local step_n = 1
        local final_status, final_reason
        while true do
            -- Phase 10: pass executor_cfg (resolved above) instead of
            -- active_cfg; safety.norris_step signature unchanged.
            local result = safety.norris_step(ctx, executor_cfg, helpers, {
                step_n    = step_n,
                max_steps = max_norris_steps,
                cfg       = config,
            })
            -- Issue #51: enforce budget after every step (was post-loop only).
            -- PHASE3.md §2 specifies sliding-window eviction mid-Norris-session
            -- when the loop runs long; this is what makes R-C3 (NORRIS suffix
            -- goal anchor surviving eviction) observable end-to-end.
            status_evictions(ctx:enforce_budget())
            if result.status == "continue" then
                step_n = step_n + 1
                -- Phase 10: advance the task pointer after each
                -- non-terminal step. When exhausted (current > #list),
                -- exit with synthesized "tasks_complete" status.
                if ctx.norris_tasks then
                    ctx.norris_tasks.current = ctx.norris_tasks.current + 1
                    if ctx.norris_tasks.current > #ctx.norris_tasks.list then
                        final_status = "tasks_complete"
                        final_reason = string.format("all %d preplanned tasks executed",
                                                     #ctx.norris_tasks.list)
                        break
                    end
                end
            else
                final_status, final_reason = result.status, result.reason
                break
            end
        end

        ctx.norris_active = false
        ctx.norris_goal   = nil
        ctx.norris_tasks  = nil  -- Phase 10: clear on exit for clean re-launch
        renderer.norris_end(final_status, final_reason)
    end

    -- Meta dispatch table.
    local meta = {
        quit  = function() shutdown_session(); os.exit(0) end,
        q     = function() shutdown_session(); os.exit(0) end,
        clear = function() io.write("\27[H\27[2J"); io.flush() end,
        reset = function()
            ctx:reset(); renderer.status("context reset")
        end,
        model = function(args)
            local name = args:match("^%s*(%S+)")
            if not name or not config.models[name] then
                renderer.status("usage: :model <name>; not found: " .. tostring(name))
                return
            end
            active_name, active_cfg = name, config.models[name]
            renderer.status("model -> " .. name)
        end,
        plan = function(args)
            local sub = (args:match("^%s*(%S*)") or ""):lower()
            if sub == "" then
                plan_mode = not plan_mode
            elseif sub == "on" then
                plan_mode = true
            elseif sub == "off" then
                plan_mode = false
            else
                renderer.status("usage: :plan [on|off]"); return
            end
            renderer.status("plan mode " .. (plan_mode and "on" or "off"))
        end,
        models = function()
            renderer.status(("models (active: %s):"):format(active_name))
            for name, cfg in pairs(config.models) do
                local mark = (name == active_name) and "*" or " "
                io.write(("  %s %-8s  %s @ %s\n"):format(
                    mark, name, cfg.model or "?", cfg.endpoint or "?"))
            end
        end,
        history = function()
            if #ctx.turns == 0 then
                renderer.status("(empty)"); return
            end
            for i, t in ipairs(ctx.turns) do
                io.write(("[%d] %s: %s\n"):format(
                    i, t.role, t.content:gsub("\n", " ")))
            end
        end,
        exec = function(args)
            args = (args or ""):match("^%s*(.-)%s*$")
            if args == "" then renderer.status("usage: :exec <cmd>"); return end
            run_shell(args)
        end,
        ask  = function(args)
            args = (args or ""):match("^%s*(.-)%s*$")
            if args == "" then renderer.status("usage: :ask <text>"); return end
            ask_ai(expand_mentions(args, renderer.status))
        end,
        sessions = function()
            if not sessions_dir then renderer.status("(no history.dir configured)"); return end
            local names = history.list_sessions(sessions_dir)
            if #names == 0 then renderer.status("(no sessions in " .. sessions_dir .. ")"); return end
            for _, n in ipairs(names) do
                local mark = (session_path and session_path:match("[^/]+$") == n)
                             and "*" or " "
                io.write(("  %s %s\n"):format(mark, n))
            end
        end,
        save = function(args)
            local name = args:match("^%s*(%S+)")
            if not name then renderer.status("usage: :save <name>"); return end
            if not (session and session_path and sessions_dir) then
                renderer.status("no active session to save")
                return
            end
            name = name:gsub("%.jsonl$", "")
            local new_path = sessions_dir .. "/" .. name .. ".jsonl"
            if new_path == session_path then
                renderer.status("already named " .. name)
                return
            end
            session:close()
            local ok, rerr = os.rename(session_path, new_path)
            if not ok then
                renderer.status("rename failed: " .. tostring(rerr))
                -- best-effort reopen of original path so logging continues
                session = history.open(session_path)
                return
            end
            session_path = new_path
            session = history.open(session_path)  -- reopen for continued append
            renderer.status("saved as " .. name .. ".jsonl")
        end,
        resume = function(args)
            local name = args:match("^%s*(%S+)")
            if not name then renderer.status("usage: :resume <name>"); return end
            if not sessions_dir then renderer.status("(no history.dir configured)"); return end
            -- Refuse to silently clobber an active conversation; the user has
            -- to :reset first to express intent. The current session log on
            -- disk is unaffected by either choice.
            if #ctx.turns > 0 then
                renderer.status(("resume into non-empty ctx refused (%d turns); :reset first")
                                :format(#ctx.turns))
                return
            end
            name = name:gsub("%.jsonl$", "")
            local path = sessions_dir .. "/" .. name .. ".jsonl"
            local turns, _meta_hdr = history.load(path)
            if not turns then
                renderer.status("resume failed: cannot load " .. path)
                return
            end
            ctx:reset()
            for _, t in ipairs(turns) do ctx:append(t) end
            renderer.status(("resumed %d turns from %s"):format(#turns, name))
        end,
        mcp = function(args)
            local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$")
            if sub == "list" or sub == "" then
                if next(mcp_sessions) == nil then
                    renderer.status("(no MCP sessions)"); return
                end
                for alias, sess in pairs(mcp_sessions) do
                    io.write(("  %s  %s  (%d tools)\n"):format(
                        alias, sess.url, #sess:list_tools()))
                end
            elseif sub == "tools" then
                local any = false
                for alias, sess in pairs(mcp_sessions) do
                    for _, t in ipairs(sess:list_tools()) do
                        any = true
                        local desc = (t.description or ""):gsub("\n", " ")
                        io.write(("  %s__%-16s %s\n"):format(
                            alias, t.name, desc:sub(1, 60)))
                    end
                end
                if not any then renderer.status("(no tools)") end
            elseif sub == "tool" then
                local name = sub_args:match("^%s*(%S+)")
                if not name then
                    renderer.status("usage: :mcp tool <alias__name>"); return
                end
                local alias, tname = name:match("^(.-)__(.+)$")
                if not alias or alias == "" then
                    renderer.status("tool name missing alias prefix: " .. name)
                    return
                end
                local sess = mcp_sessions[alias]
                if not sess then
                    renderer.status("unknown alias: " .. alias)
                    return
                end
                local found
                for _, t in ipairs(sess:list_tools()) do
                    if t.name == tname then found = t; break end
                end
                if not found then
                    renderer.status("unknown tool: " .. name); return
                end
                io.write(("  %s__%s\n"):format(alias, found.name))
                io.write(("  description: %s\n"):format(found.description or "(none)"))
                io.write("  inputSchema:\n    ")
                io.write((json.encode(found.inputSchema or {}, {indent = true})
                          :gsub("\n", "\n    ")))
                io.write("\n")
            elseif sub == "connect" then
                local url, alias = sub_args:match("^%s*(%S+)%s*(%S*)")
                if not url or url == "" then
                    renderer.status("usage: :mcp connect <url> [alias]"); return
                end
                if alias == "" then
                    alias = url:match("https?://([^:/]+)") or url
                end
                if mcp_sessions[alias] then
                    renderer.status("already connected: " .. alias); return
                end
                local ok, n = connect_mcp(alias, { url = url })
                if ok then
                    renderer.status(("mcp %s: connected (%d tools)")
                                    :format(alias, n))
                    -- Re-validate auto_approve so any stale keys that
                    -- referenced this alias become live (issue #33 bonus).
                    validate_auto_approve()
                end
            elseif sub == "disconnect" then
                local alias = sub_args:match("^%s*(%S+)")
                if not alias then
                    renderer.status("usage: :mcp disconnect <alias>"); return
                end
                local sess = mcp_sessions[alias]
                if not sess then
                    renderer.status("not connected: " .. alias); return
                end
                sess:close()
                mcp_sessions[alias] = nil
                renderer.status("disconnected " .. alias)
            else
                renderer.status("usage: :mcp {list|tools|tool|connect|disconnect}")
            end
        end,
        norris = function(args)
            local sub = args:match("^%s*(%S*)")
            if sub == "off" then
                if ctx.norris_active then
                    ctx.norris_active = false
                    ctx.norris_goal   = nil
                    renderer.status("Norris mode off")
                else
                    renderer.status("Norris mode is not active")
                end
                return
            end
            local goal = args:match("^%s*(.-)%s*$")
            if not goal or goal == "" then
                renderer.status("usage: :norris <goal text>"); return
            end
            run_norris(goal)
        end,
        remember = function(args)
            local text = args:match("^%s*(.-)%s*$")
            if not text or text == "" then
                renderer.status("usage: :remember <text>"); return
            end
            if not memory then renderer.status("memory unavailable"); return end
            local id = memory:add("fact", text)
            inject_memory()  -- refresh live ctx so the next AI turn sees it
            renderer.status(("remembered as id=%d (fact)"):format(id))
        end,
        memory = function(args)
            local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$")
            if sub == "" or sub == "list" then
                if not memory_path then
                    renderer.status("memory unavailable (no history.dir)"); return
                end
                local items = history.load_memory(memory_path)
                if #items == 0 then
                    renderer.status("(no memory items)"); return
                end
                for _, it in ipairs(items) do
                    io.write(("  %3d  %s  %-7s  %s\n"):format(
                        it.id, it.ts, it.kind,
                        (it.content or ""):gsub("\n", " "):sub(1, 80)))
                end
            elseif sub == "add" then
                if not memory then renderer.status("memory unavailable"); return end
                local kind, body = sub_args:match("^%s*(%S+)%s+(.+)$")
                if not kind or not body then
                    renderer.status("usage: :memory add <fact|pref|context> <text>"); return
                end
                if kind ~= "fact" and kind ~= "pref" and kind ~= "context" then
                    renderer.status("kind must be fact, pref, or context"); return
                end
                local id = memory:add(kind, body:gsub("^%s+", ""):gsub("%s+$", ""))
                inject_memory()
                renderer.status(("added id=%d (%s)"):format(id, kind))
            elseif sub == "forget" then
                if not memory then renderer.status("memory unavailable"); return end
                local id = tonumber(sub_args:match("^%s*(%d+)"))
                if not id then renderer.status("usage: :memory forget <id>"); return end
                -- N1: check active set first; surface status if id isn't active
                local items = history.load_memory(memory_path)
                local found = false
                for _, it in ipairs(items) do
                    if it.id == id then found = true; break end
                end
                if not found then
                    renderer.status(("id %d not active (already forgotten or never existed)"):format(id))
                    return
                end
                memory:forget(id)
                inject_memory()
                renderer.status(("forgot id=%d"):format(id))
            elseif sub == "clear" then
                if not memory then renderer.status("memory unavailable"); return end
                local items = history.load_memory(memory_path)
                if #items == 0 then renderer.status("(no items to clear)"); return end
                local ans = rl.readline(
                    ("forget all %d active memory items? [y/N] "):format(#items))
                    or ""
                if ans:lower():sub(1,1) ~= "y" then
                    renderer.status("clear cancelled"); return
                end
                for _, it in ipairs(items) do memory:forget(it.id) end
                inject_memory()
                renderer.status(("cleared %d items"):format(#items))
            elseif sub == "inject" then
                inject_memory()
                renderer.status(("re-injected %d items"):format(
                    (ctx.memory_items and #ctx.memory_items) or 0))
            elseif sub == "summarize" then
                -- #102: body extracted into _do_memory_summarize so
                -- shutdown_session can fire it with auto=true on :q.
                _do_memory_summarize({ auto = false })
            else
                renderer.status("usage: :memory {list|add|forget|clear|inject|summarize}")
            end
        end,
        safety = function(args)
            local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$")
            if sub == "patterns" then
                for i, rule in ipairs(safety._patterns) do
                    local ci = rule.ci and " (ci)" or ""
                    io.write(("  %2d. %-32s  %s%s\n"):format(
                        i, rule.reason, rule.pat, ci))
                end
            elseif sub == "check" then
                local cmd = sub_args:match("^%s*(.-)%s*$")
                if not cmd or cmd == "" then
                    renderer.status("usage: :safety check <cmd>"); return
                end
                -- Pass cfg so the LLM probe runs; user can opt-out via
                -- :safety check --no-llm <cmd> if added in v2.
                -- Issue #52: thread secrets scrub/rehydrate so the probe
                -- model sees placeholders for any secrets in `cmd`.
                -- Phase 7: also thread on_usage so the probe's cost
                -- lands in the accumulator under category="probe".
                local probe_opts = { on_usage = _record_usage }
                if secrets_session then
                    probe_opts.scrub_msgs = function(msgs, mode_cfg)
                        return scrub_messages(msgs,
                            secrets_mode_for(mode_cfg or active_cfg))
                    end
                    probe_opts.rehydrate = function(t)
                        return secrets_session:rehydrate(t)
                    end
                end
                local hit, reason = safety.is_destructive(cmd, config, probe_opts)
                if hit then
                    renderer.status(("DESTRUCTIVE — %s"):format(reason or "?"))
                else
                    renderer.status("not destructive")
                end
            else
                renderer.status("usage: :safety {patterns|check}")
            end
        end,
        perms = function(args)
            local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$")
            if sub == "list" or sub == "" then
                local p = config.permissions
                if not p then
                    renderer.status(("(no permissions set; fallback: confirm_cmd=%s)")
                        :format(tostring(config.shell and config.shell.confirm_cmd or false)))
                    return
                end
                local function dump(label, rules)
                    if not rules or #rules == 0 then return end
                    io.write(("  %s:\n"):format(label))
                    for i, r in ipairs(rules) do
                        io.write(("    %2d. %s\n"):format(i, r))
                    end
                end
                renderer.status("permissions (deny > confirm > allow; default verdict: confirm):")
                dump("deny",    p.deny)
                dump("confirm", p.confirm)
                dump("allow",   p.allow)
            elseif sub == "check" then
                local cmd = sub_args:match("^%s*(.-)%s*$")
                if not cmd or cmd == "" then
                    renderer.status("usage: :perms check <cmd>"); return
                end
                local v, rule = safety.classify_command(cmd, config)
                renderer.status(("verdict=%s rule=%s"):format(v, rule or "(default)"))
            else
                renderer.status("usage: :perms {list|check}")
            end
        end,
        route = function(args)
            local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$")
            config.routing = config.routing or {}
            if sub == "on" then
                config.routing.auto = true
                renderer.status("auto-routing on")
            elseif sub == "off" then
                config.routing.auto = false
                renderer.status("auto-routing off")
            elseif sub == "classes" then
                local classes = config.routing.classes or {}
                if next(classes) == nil then
                    renderer.status("(no classes configured)"); return
                end
                for k, v in pairs(classes) do
                    io.write(("  %-10s → %s\n"):format(k, tostring(v)))
                end
            elseif sub == "check" then
                local text = sub_args:match("^%s*(.-)%s*$")
                if not text or text == "" then
                    renderer.status("usage: :route check <text>"); return
                end
                local m, class = router.classify_model(text, config)
                local extra = config.routing.auto and ""
                              or " (routing currently disabled)"
                renderer.status(("class=%s model=%s%s"):format(
                    class, tostring(m), extra))
            else
                renderer.status("usage: :route {on|off|classes|check}")
            end
        end,
        fallback = function(args)
            local sub = args:match("^%s*(%S*)")
            config.routing = config.routing or {}
            if sub == "on" then
                config.routing.fallback = true
                renderer.status(("cloud fallback on (target: %s)"):format(
                    config.routing.fallback_model or "cloud"))
            elseif sub == "off" then
                config.routing.fallback = false
                renderer.status("cloud fallback off")
            else
                renderer.status("usage: :fallback {on|off}")
            end
        end,
        help = function() io.write(HELP) end,
    }

    -- Issue #2: user-defined skills loader. Scan ~/.config/aish/skills/
    -- (or $AISH_SKILLS_DIR) for *.lua modules. Each module returns
    -- { name = "<meta-cmd-name>", description = "...", run = function(args, h) end }
    -- and gets registered as a meta command :<name>. Helpers passed to run():
    --   h.ask(text)     -- send text as an ai-kind prompt (same path as :ask)
    --   h.status(s)     -- emit a [aish] status line
    --   h.exec(cmd)     -- run a shell command (subject to plan/hooks)
    --   h.model()       -- current active model name
    --   h.ctx           -- raw context object (advanced)
    --   h.config        -- raw config table
    local skills = {}  -- { [name] = {description=, run=} }
    local function load_skills()
        local dir = os.getenv("AISH_SKILLS_DIR")
                    or ((os.getenv("HOME") or ".") .. "/.config/aish/skills")
        local pipe = io.popen(("ls -1 %q/*.lua 2>/dev/null"):format(dir))
        if not pipe then return end
        for path in pipe:lines() do
            local ok, mod = pcall(dofile, path)
            if not ok then
                renderer.status(("skill load failed: %s: %s")
                                :format(path, tostring(mod)))
            elseif type(mod) ~= "table"
                or type(mod.name) ~= "string"
                or type(mod.run) ~= "function"
                or not mod.name:match("^[%w_-]+$")
            then
                renderer.status(("skill %s: invalid module (need {name, run})")
                                :format(path))
            elseif meta[mod.name] or skills[mod.name] then
                renderer.status(("skill %s: name '%s' already in use")
                                :format(path, mod.name))
            else
                skills[mod.name] = {
                    description = mod.description or "",
                    run         = mod.run,
                }
                local helpers = {
                    ask    = function(t) ask_ai(expand_mentions(t or "", renderer.status)) end,
                    status = renderer.status,
                    exec   = run_shell,
                    model  = function() return active_name end,
                    ctx    = ctx,
                    config = config,
                }
                meta[mod.name] = function(args)
                    local sk_ok, sk_err = pcall(mod.run, args or "", helpers)
                    if not sk_ok then
                        renderer.status(("skill %s failed: %s")
                                        :format(mod.name, tostring(sk_err)))
                    end
                end
            end
        end
        pipe:close()
    end
    meta.skills = function()
        local names = {}
        for n, _ in pairs(skills) do names[#names + 1] = n end
        table.sort(names)
        if #names == 0 then renderer.status("(no skills loaded)"); return end
        renderer.status(("skills (%d):"):format(#names))
        for _, n in ipairs(names) do
            io.write(("  :%-16s %s\n"):format(n, skills[n].description))
        end
    end

    -- Issue #13: :secrets meta — vault status, current mode per active
    -- broker, mapping size. Never prints actual values (the vault file
    -- is itself a secret, gotcha 7).
    meta.secrets = function(args)
        local sub = args:match("^%s*(%S*)") or ""
        if sub == "" or sub == "status" then
            if not secrets_session then
                renderer.status("(no vault loaded; configure config.secrets.vault)")
                return
            end
            renderer.status(("vault: %d entries; %d placeholders allocated this session")
                :format(#secrets_session.entries, secrets_session:mapping_size()))
            renderer.status(("active broker mode: %s"):format(secrets_mode_for(active_cfg)))
            local names = secrets_session:vault_names()
            if #names > 0 then
                io.write("  entry names: " .. table.concat(names, ", ") .. "\n")
            end
        elseif sub == "check" then
            -- Run a scrub against the given text and report what would change.
            local text = args:match("^%s*check%s+(.+)$") or ""
            if text == "" then renderer.status("usage: :secrets check <text>"); return end
            if not secrets_session then renderer.status("(no vault loaded)"); return end
            local mode = secrets_mode_for(active_cfg)
            local scrubbed = secrets_session:scrub(text, mode)
            if scrubbed == text then
                renderer.status(("no matches (mode=%s)"):format(mode))
            else
                renderer.status(("scrubbed (mode=%s):"):format(mode))
                io.write("  " .. scrubbed .. "\n")
            end
        else
            renderer.status("usage: :secrets [status|check <text>]")
        end
    end

    load_skills()

    -- Issue #11: in-session recurring prompts (:every). Pre-prompt due-check
    -- model: timers fire between user inputs, not during readline waits or
    -- broker calls. This is the minimum viable approach without rewriting
    -- ffi/readline to callback-mode. Suppressed during Norris.
    local every_jobs = {}    -- { {id, interval_s, next_fire, prompt, model_name}, ... }
    local next_every_id = 1
    local function _parse_interval(s)
        s = (s or ""):gsub("%s+", "")
        local num, unit = s:match("^(%d+)([smh]?)$")
        if not num then return nil end
        local mult = ({ s = 1, m = 60, h = 3600, [""] = 1 })[unit]
        return tonumber(num) * mult
    end
    local function _every_fire(job)
        renderer.status(("[every #%d tick: %s]")
                        :format(job.id, job.prompt))
        -- Temporarily swap to the job's chosen model so the recurring prompt
        -- hits the preset selected at :every time (defaulted to "fast").
        local saved_name, saved_cfg = active_name, active_cfg
        if config.models[job.model_name] then
            active_name, active_cfg = job.model_name, config.models[job.model_name]
        end
        local ok, err = pcall(ask_ai, job.prompt)
        active_name, active_cfg = saved_name, saved_cfg
        if not ok then
            renderer.status(("[every #%d failed: %s]"):format(job.id, tostring(err)))
        end
    end
    local function check_every_due()
        if ctx.norris_active then return end
        local now = os.time()
        -- Snapshot the due jobs so a long-running tick doesn't compound.
        local due = {}
        for _, j in ipairs(every_jobs) do
            if now >= j.next_fire then due[#due + 1] = j end
        end
        for _, j in ipairs(due) do
            j.next_fire = os.time() + j.interval_s
            _every_fire(j)
        end
    end
    -- Phase 6: :tree meta — scan + inject project file-tree as the
    -- [project] block in the system prompt. Variants per §6:
    --   :tree            scan with config defaults; resets _project_opts
    --   :tree <N>        scan with depth=N; cached as _project_opts
    --   :tree refresh    re-scan with cached opts; else config defaults
    --   :tree off        clear ctx.project AND ctx._project_opts
    -- Phase 6: :highlight meta — toggle tree-sitter highlighter.
    --   :highlight              flip current setting
    --   :highlight on           enable (status warns if CLI absent
    --                            AND/OR parsers may not be installed)
    --   :highlight off          disable; renderer passes through
    --   :highlight status       report toggle + CLI detection state
    meta.highlight = function(args)
        local sub = ((args or ""):match("^%s*(%S*)") or ""):lower()
        if sub == "status" then
            renderer.status(("highlight: %s (tree-sitter CLI %s)"):format(
                highlight_enabled and "on" or "off",
                highlight_detected and "detected" or "absent"))
            return
        end
        if sub == "" then
            highlight_enabled = not highlight_enabled
        elseif sub == "on" then
            highlight_enabled = true
        elseif sub == "off" then
            highlight_enabled = false
        else
            renderer.status("usage: :highlight [on|off|status]")
            return
        end
        renderer.set_highlight(highlight_enabled, highlight_detected, highlighted)
        if highlight_enabled and not highlight_detected then
            -- B4: install hint when toggled on but CLI absent. Also note
            -- the parser-directory + grammar-clone requirement that
            -- catches users who installed only the CLI.
            renderer.status("highlight on but tree-sitter CLI not found.")
            renderer.status("install: `apt install tree-sitter-cli` OR `cargo install tree-sitter-cli`")
            renderer.status("then: `tree-sitter init-config` AND clone the relevant")
            renderer.status("`tree-sitter-<lang>` grammars into a parser directory.")
        elseif highlight_enabled then
            renderer.status("highlight on (note: needs parser-directories with built tree-sitter-<lang> grammars)")
        else
            renderer.status("highlight off")
        end
    end
    -- Phase 6: :diff meta — `git diff <args>` (B1-clean), appends as
    -- [diff <args>]\n<output> exec_output. Reads cwd at invocation
    -- time (R6: differs from :tree's scan-time cwd capture). Empty
    -- Phase 7: :cost meta — read-only reporter of ctx.usage_totals.
    --   :cost            summary line
    --   :cost detail     per-model + per-category breakdown
    --   :cost reset      zero the meter (also clears warn flags)
    -- R7 sort: (cost desc, model asc, category asc) — table.sort is
    -- unstable, so the 3-level key ensures deterministic output.
    -- Phase 9: :config show meta. Reads config._sources (R3 cfg-
    -- embedded source map from main.lua's load_config_with_overlay)
    -- + the effective config. Sanitizes token-bearing fields per
    -- the masking heuristic (any key containing token/secret/auth/
    -- key, case-insensitive -> "(set)" instead of the value).
    --
    -- R6: `:config show` (default) shows top-level keys with nested
    -- tables collapsed to their inner-key list; `:config show full`
    -- recurses and applies the same masking heuristic at every level.
    -- N2 known false positive: key_env / auth_env hold env-var
    -- names (not secrets) but match the heuristic; future polish
    -- exempts `*_env` patterns.
    local _CFG_MASK_RE = "token|secret|auth|key"  -- pipe-OR via gsub
    local function _is_sensitive_key(k)
        local lk = tostring(k):lower()
        return lk:find("token", 1, true)
            or lk:find("secret", 1, true)
            or lk:find("auth",   1, true)
            or lk:find("key",    1, true)
    end
    local function _fmt_value(v, full, depth)
        depth = depth or 0
        if type(v) == "string" then return string.format("%q", v) end
        if type(v) == "number" or type(v) == "boolean" then return tostring(v) end
        if type(v) == "function" then return "<function>" end
        if type(v) == "table" then
            local keys = {}
            for k, _ in pairs(v) do keys[#keys + 1] = tostring(k) end
            table.sort(keys)
            if not full then
                return "{" .. table.concat(keys, ", ") .. "}"
            end
            -- Full mode: recurse, masking sensitive keys.
            if depth >= 5 then return "{...}" end  -- defensive depth cap
            local parts = {}
            for _, k in ipairs(keys) do
                local val
                if _is_sensitive_key(k) then
                    val = "(set)"
                else
                    val = _fmt_value(v[k], true, depth + 1)
                end
                parts[#parts + 1] = ("%s = %s"):format(k, val)
            end
            return "{" .. table.concat(parts, ", ") .. "}"
        end
        return "<" .. type(v) .. ">"
    end
    meta.config = function(args)
        local sub = ((args or ""):match("^%s*(%S*)") or ""):lower()
        if sub ~= "" and sub ~= "show" then
            renderer.status("usage: :config show [full]")
            return
        end
        local full = (args or ""):lower():match("show%s+full") ~= nil
        local sources = config._sources
        if sources then
            -- Group by source for the "config sources" listing.
            local user_keys, project_keys = {}, {}
            for k, src in pairs(sources) do
                if src == "project" then project_keys[#project_keys + 1] = k
                else                     user_keys[#user_keys + 1] = k end
            end
            table.sort(user_keys); table.sort(project_keys)
            renderer.status("config sources:")
            io.write(("  user:    %s keys: %s\n"):format(
                #user_keys, table.concat(user_keys, ", ")))
            if #project_keys > 0 then
                io.write(("  project: %s keys: %s\n"):format(
                    #project_keys, table.concat(project_keys, ", ")))
            end
        else
            renderer.status("config sources: (unknown — main didn't pass _sources)")
        end
        renderer.status(full and "effective config (full, sensitive masked):"
                              or "effective config (top-level; ':config show full' for deep):")
        local top_keys = {}
        for k, _ in pairs(config) do
            if k ~= "_sources" then top_keys[#top_keys + 1] = k end
        end
        table.sort(top_keys)
        for _, k in ipairs(top_keys) do
            local source_tag = sources and sources[k] or "?"
            local val
            if _is_sensitive_key(k) then
                val = "(set)"
            else
                val = _fmt_value(config[k], full)
            end
            io.write(("  %-20s = %s  [%s]\n"):format(k, val, source_tag))
        end
    end

    -- R10: $%.6f for sub-cent precision (cloud costs can be ~3e-05).
    -- R6: annotation uses the per-slot is_local sticky flag rather
    -- than a fragile cost==0 heuristic.
    meta.cost = function(args)
        local sub = ((args or ""):match("^%s*(%S*)") or ""):lower()
        if sub == "reset" then
            ctx:reset_usage()
            renderer.status("cost meter reset")
            return
        end
        local total_cost  = ctx:total_cost()
        local total_p, total_c = ctx:total_tokens()
        local has_local, has_cloud = false, false
        for _, m in pairs(ctx.usage_totals or {}) do
            for _, c in pairs(m) do
                if c.is_local then has_local = true end
                if c.cost > 0 then has_cloud = true end
            end
        end
        local label
        if has_local and has_cloud then
            label = "(cloud only; local: tokens but no cost field)"
        elseif has_local and not has_cloud then
            label = "(local only; no cost field)"
        else
            label = ""
        end
        if sub == "" then
            local calls = 0
            for _, m in pairs(ctx.usage_totals or {}) do
                for _, c in pairs(m) do calls = calls + c.calls end
            end
            renderer.status(("session usage: %d calls, prompt=%d / completion=%d tokens, cost=$%.6f %s"):format(
                calls, total_p, total_c, total_cost, label))
            return
        end
        if sub == "detail" then
            local rows = {}
            for model, cats in pairs(ctx.usage_totals or {}) do
                for category, c in pairs(cats) do
                    rows[#rows + 1] = {
                        model = model, category = category,
                        prompt = c.prompt, completion = c.completion,
                        calls  = c.calls,  cost = c.cost,
                        is_local = c.is_local,
                    }
                end
            end
            if #rows == 0 then renderer.status("(no usage recorded)"); return end
            -- R7: 3-level deterministic sort
            table.sort(rows, function(a, b)
                if a.cost ~= b.cost     then return a.cost > b.cost end
                if a.model ~= b.model   then return a.model < b.model end
                return a.category < b.category
            end)
            renderer.status(("session usage detail (total=$%.6f, %d/%d tokens):"):format(
                total_cost, total_p, total_c))
            for _, r in ipairs(rows) do
                io.write(("  %-26s %-18s %3d calls, %6d / %6d tokens, $%.6f%s\n"):format(
                    r.model, r.category, r.calls, r.prompt, r.completion, r.cost,
                    r.is_local and "  (local)" or ""))
            end
            -- Phase 8 R3: trailing summary line — current ctx snapshot
            -- (NOT a comparison against the accumulator sums above; the
            -- accumulator carries cumulative across all calls including
            -- evicted turns, while estimate_tokens is current-in-memory
            -- only). Shows budget utilization at-a-glance.
            local est       = ctx:estimate_tokens()
            local budget    = ctx.token_budget or 0
            local pct       = (budget > 0) and (est * 100 / budget) or 0
            renderer.status(("estimated session ctx: %d tokens; token_budget=%d (%.1f%% used)"):format(
                est, budget, pct))
            return
        end
        renderer.status("usage: :cost [detail|reset]")
    end
    -- diff or git failure emits status and skips — never pollutes
    -- context with empty or error noise.
    meta.diff = function(args)
        args = (args or ""):gsub("^%s+", ""):gsub("%s+$", "")
        local cmd = _git_clean_cmd("diff " .. args)
        local out, code = executor.exec(cmd)
        if code ~= 0 then
            renderer.status(("diff failed (exit %d): %s")
                            :format(code, args == "" and "(working tree)" or args))
            return
        end
        if not out or out:gsub("%s", "") == "" then
            renderer.status(("(no diff): %s"):format(
                args == "" and "(working tree)" or args))
            return
        end
        local label = args == "" and "(working tree)" or args
        ctx:append_exec_output(("[diff %s]\n%s"):format(label, out))
        renderer.status(("diff injected: %s (%d bytes)"):format(label, #out))
    end
    meta.tree = function(args)
        local sub = (args or ""):match("^%s*(%S*)") or ""
        if sub == "off" then
            ctx.project       = nil
            ctx._project_opts = nil
            renderer.status("project tree cleared")
            return
        end
        local opts
        if sub == "refresh" then
            opts = ctx._project_opts or {}
        elseif sub == "" then
            opts = {}
            ctx._project_opts = nil
        else
            local n = tonumber(sub)
            if not n or n < 1 then
                renderer.status("usage: :tree [<depth>|refresh|off]"); return
            end
            opts = { depth = n }
            ctx._project_opts = opts
        end
        local dir = libc.getcwd() or "."
        local body, info = _scan_project_tree(dir, opts)
        if not body then
            renderer.status("tree scan failed: " .. tostring(info))
            return
        end
        ctx.project = body
        renderer.status(("project tree: %d files%s (%s)"):format(
            info.file_count,
            info.truncated and " (truncated)" or "",
            info.in_git and "git ls-files" or "find fallback"))
    end
    meta.every = function(args)
        local sub = args:match("^%s*(%S*)") or ""
        if sub == "list" or sub == "" and args:match("^%s*$") then
            if #every_jobs == 0 then
                renderer.status("(no recurring prompts)"); return
            end
            local now = os.time()
            renderer.status(("recurring prompts (%d):"):format(#every_jobs))
            for _, j in ipairs(every_jobs) do
                io.write(("  #%d  every %ds (next in %ds, model=%s)  %s\n")
                    :format(j.id, j.interval_s, j.next_fire - now, j.model_name, j.prompt))
            end
            return
        end
        if sub == "cancel" then
            local id = tonumber(args:match("cancel%s+(%d+)"))
            if not id then renderer.status("usage: :every cancel <id>"); return end
            for i, j in ipairs(every_jobs) do
                if j.id == id then
                    table.remove(every_jobs, i)
                    renderer.status(("cancelled #%d"):format(id)); return
                end
            end
            renderer.status(("no such job: #%d"):format(id)); return
        end
        -- :every <interval> <prompt...>  (prompt may be quoted; quotes stripped)
        local interval_s, rest = args:match("^%s*(%S+)%s+(.+)$")
        local secs = _parse_interval(interval_s)
        if not secs or secs < 1 then
            renderer.status("usage: :every <interval> <prompt>  (interval: 30s | 5m | 2h | bare int)")
            return
        end
        local p = rest:gsub("^%s+", ""):gsub("%s+$", "")
        p = p:match("^\"(.*)\"$") or p:match("^'(.*)'$") or p
        if p == "" then renderer.status("usage: :every <interval> <prompt>"); return end
        local job_model = (config.models and config.models.fast) and "fast" or active_name
        local id = next_every_id; next_every_id = next_every_id + 1
        every_jobs[#every_jobs + 1] = {
            id          = id,
            interval_s  = secs,
            next_fire   = os.time() + secs,
            prompt      = p,
            model_name  = job_model,
        }
        renderer.status(("scheduled #%d every %ds (model=%s): %s")
                        :format(id, secs, job_model, p))
    end

    -- Issue #8: background CMD (CMD&: marker). Spawn via a shell wrapper
    -- that captures stdout+stderr to <history.dir>/bg/<id>.log and the
    -- exit code to <id>.status. We poll with kill -0; on completion read
    -- the .status sidecar. No fork()/execv() FFI required — relies on POSIX
    -- shell semantics. Reparented child is owned by init; we treat it as
    -- "managed" via the PID and the status file only.
    local bg_jobs = {}        -- { {id, pid, cmd, started_at, log_path, status_path, exited} }
    local next_bg_id = 1
    local bg_dir = history_dir and (history_dir .. "/bg") or nil
    if bg_dir then os.execute(("mkdir -p %q 2>/dev/null"):format(bg_dir)) end

    local function _bg_shq(s) return "'" .. (s or ""):gsub("'", [['\'']]) .. "'" end
    _bg_spawn = function(cmd)
        if not bg_dir then
            return nil, "background CMD requires history.dir to be configured"
        end
        local id = next_bg_id; next_bg_id = next_bg_id + 1
        local log_path    = ("%s/%d.log"):format(bg_dir, id)
        local status_path = ("%s/%d.status"):format(bg_dir, id)
        -- Wrapper: redirect, capture exit, write status. nohup + </dev/null
        -- so the child survives our exit and doesn't compete for stdin.
        -- Use `(...) &` so the subshell that wraps the exit-capture is
        -- itself backgrounded; we echo $! to capture its PID.
        local wrapper = ("nohup sh -c %s </dev/null >/dev/null 2>&1 & echo $!"):format(
            _bg_shq(("(%s) > %s 2>&1; echo $? > %s"):format(
                cmd, _bg_shq(log_path), _bg_shq(status_path))))
        local pipe = io.popen(wrapper)
        local pid_str = pipe and pipe:read("*l")
        if pipe then pipe:close() end
        local pid = tonumber(pid_str)
        if not pid then
            return nil, "failed to spawn (no PID returned)"
        end
        local job = {
            id          = id,
            pid         = pid,
            cmd         = cmd,
            started_at  = os.time(),
            log_path    = log_path,
            status_path = status_path,
            exited      = false,
        }
        bg_jobs[#bg_jobs + 1] = job
        return job
    end
    local function _bg_status_check(job)
        if job.exited then return end
        -- Read status file: presence means the wrapper finished writing
        -- the exit code. If absent and PID is still alive, job is running.
        local f = io.open(job.status_path, "rb")
        if f then
            local s = f:read("*l") or ""
            f:close()
            job.exit_code = tonumber(s) or -1
            job.exited    = true
            job.exited_at = os.time()
            local lf = io.open(job.log_path, "rb")
            job.log_bytes = 0
            if lf then
                lf:seek("end"); job.log_bytes = lf:seek(); lf:close()
            end
        end
    end
    local function _fmt_bytes(n)
        if n < 1024       then return ("%dB"):format(n) end
        if n < 1024*1024  then return ("%.1fKB"):format(n/1024) end
        return ("%.1fMB"):format(n/(1024*1024))
    end
    local function check_bg_done()
        for _, job in ipairs(bg_jobs) do
            if not job.exited then
                _bg_status_check(job)
                if job.exited then
                    local wall = (job.exited_at or os.time()) - job.started_at
                    local summary = ("[bg:%d exited %d, %s, %ds wall] %s")
                        :format(job.id, job.exit_code,
                                _fmt_bytes(job.log_bytes or 0), wall, job.cmd)
                    renderer.status(summary)
                    -- Feed back into context so the model sees completion
                    -- on the next ai turn — same channel as foreground exec.
                    ctx:append_exec_output(summary)
                end
            end
        end
    end

    meta.delegate = function(args)
        local preset, prompt = args:match("^%s*(%S+)%s+(.+)$")
        if not preset then
            renderer.status("usage: :delegate <preset> <prompt>"); return
        end
        prompt = prompt:gsub("^%s+", ""):gsub("%s+$", "")
        prompt = prompt:match([[^"(.*)"$]]) or prompt:match([[^'(.*)'$]]) or prompt
        if prompt == "" then renderer.status("usage: :delegate <preset> <prompt>"); return end
        local sub_cfg = config.models[preset]
        if not sub_cfg then
            renderer.status(("unknown preset: %s"):format(preset)); return
        end
        renderer.status(("DELEGATE -> %s: %s"):format(preset, prompt))
        local sub_msgs = scrub_messages(
            { { role = "user", content = prompt } },
            secrets_mode_for(sub_cfg))
        -- Phase 7: capture (text, usage); second is err on failure.
        local sub_text, second = broker.chat(sub_cfg, sub_msgs,
            { category = "delegate" })
        if not sub_text then
            renderer.status(("delegate %s failed: %s"):format(preset, tostring(second)))
        else
            if second then  -- usage payload
                _record_usage(second.model, second.category, second)
            end
            if secrets_session then
                sub_text = secrets_session:rehydrate(sub_text)
            end
            io.write(sub_text)
            if not sub_text:match("\n$") then io.write("\n") end
            ctx:append_exec_output(("[delegate %s]: %s"):format(preset, sub_text))
        end
    end
    meta["bg-spawn"] = function(args)
        local cmd = (args or ""):match("^%s*(.-)%s*$")
        if cmd == "" then renderer.status("usage: :bg-spawn <cmd>"); return end
        local job, err = _bg_spawn(cmd)
        if not job then
            renderer.status("bg spawn failed: " .. tostring(err))
        else
            renderer.status(("started #%d pid=%d: %s")
                            :format(job.id, job.pid, cmd))
        end
    end
    meta["bg-list"] = function()
        if #bg_jobs == 0 then renderer.status("(no bg jobs)"); return end
        check_bg_done()
        renderer.status(("bg jobs (%d):"):format(#bg_jobs))
        for _, j in ipairs(bg_jobs) do
            local state
            if j.exited then
                state = ("exit=%d %ds"):format(j.exit_code,
                                              (j.exited_at - j.started_at))
            else
                local age = os.time() - j.started_at
                state = ("running pid=%d %ds"):format(j.pid, age)
            end
            io.write(("  #%-3d %s  %s\n"):format(j.id, state, j.cmd))
        end
    end
    meta["bg-output"] = function(args)
        local id = tonumber(args:match("^%s*(%d+)"))
        if not id then renderer.status("usage: :bg-output <id>"); return end
        local job
        for _, j in ipairs(bg_jobs) do if j.id == id then job = j; break end end
        if not job then renderer.status("no such bg job: #" .. id); return end
        local f = io.open(job.log_path, "rb")
        if not f then renderer.status("(no log file yet)"); return end
        io.write(f:read("*a") or ""); f:close()
        if not job.log_path:match("\n$") then io.write("\n") end
    end
    meta["bg-kill"] = function(args)
        local id = tonumber(args:match("^%s*(%d+)"))
        if not id then renderer.status("usage: :bg-kill <id>"); return end
        for _, j in ipairs(bg_jobs) do
            if j.id == id then
                if j.exited then
                    renderer.status(("#%d already exited"):format(id))
                else
                    os.execute(("kill %d 2>/dev/null"):format(j.pid))
                    renderer.status(("sent SIGTERM to #%d (pid %d)"):format(id, j.pid))
                end
                return
            end
        end
        renderer.status("no such bg job: #" .. id)
    end

    -- Phase 6: cfg.project.auto_tree startup hook. Runs once before the
    -- main loop opens; opts.dir = cwd at startup. Failures are status-
    -- logged once and skipped — the rest of the REPL works fine.
    -- :tree refresh later picks up cwd changes (cd intercept doesn't
    -- auto-refresh per A8 — v2 polish).
    if config.project and config.project.auto_tree then
        local dir = libc.getcwd() or "."
        local body, info = _scan_project_tree(dir, {})
        if body then
            ctx.project = body
            renderer.status(("project tree auto-injected: %d files%s (%s)")
                :format(info.file_count,
                        info.truncated and " (truncated)" or "",
                        info.in_git and "git ls-files" or "find fallback"))
        else
            renderer.status("project tree auto-inject failed: " .. tostring(info))
        end
    end

    -- Main loop.
    while true do
        check_every_due()
        check_bg_done()
        local line = rl.readline(prompt())
        if line == nil then          -- EOF (Ctrl-D on empty line)
            io.write("\n")
            shutdown_session()
            break
        end
        if line:gsub("%s", "") == "" then
            -- empty / whitespace-only: skip silently
        else
            rl.add_history(line)
            local kind, payload = router.classify(line, config)
            if kind == "meta" then
                local name, rest = payload:match("^(%S+)%s*(.*)$")
                local handler = name and meta[name]
                if handler then
                    handler(rest or "")
                else
                    renderer.status("unknown meta command: :" .. tostring(name))
                end
            elseif kind == "shell" then
                run_shell(payload)
            else  -- "ai"
                local expanded = expand_mentions(payload, renderer.status)
                ask_ai(expanded)
            end
        end
    end
end

-- Phase 0 module export. Meta-command list shown above lives in HELP and
-- is implemented inline in run().
return M
